Get Rid of Mixed Callables with PHPStan

Working with PHPStan on level 8 is a luxury, at least for a project where you've just introduced it. To give you a practical non-open-source perspective: we're at level 5 after 2 years of hard work.

In this level, we've noticed that PHPStan skips expression on variables that it resolved as mixed. Level 5 already provides valuable checks that we wanted for all method calls and property fetches. So we added custom rule to report those.

After we cut the low-hanging fruit, we discovered the same way are skipped all callable types. How to deal with those?

Proof over theory? Thanks to this PHPStan rule, the Symplify improved 8 callables from mixed to param and return typed.

mixed !== mixed

Dealing with mixed is so hard. That's why it's included in the highest level of PHPStan - level 9.

Let's look at quite a typical code snippet. How would you this detect type?

final class TripController
    public function showFlights($destination)
        // what exactly is $destination?
        echo $destination;

This can be tricky with scalar variables and entry points like controller or API calls. To be 100 % sure, we have to run the code collect production types with dynamic analysis, verify the user input, etc.

We won't deal with those now. Instead, we take the low hanging fruit, like method calls or property fetches:

public function fetchFlights($destination): array

For the human eye, it's obvious how to type this parameter. But how to detect these with PHPStan? See the previous post for unlocking the secret.

But today, we'll look at something different.

From Typed Method to a Callable

Try to follow this analogy with class structure. We have a class with 1 method, with clearly defined types:

final class CovidRestrictionResolver implements RestrictionResolverInterface
    public function resolve(Destination $destination): Restrictions
        // ...

Now we go one level lower, from class method to a function. We still have types and know what comes there:

function resolve(Destination $destination): Restrictions
    // ...

Let's say we need to use callable for JSON input/output, parallel run, or because we love functional programming.

$restrictionResolver = function (Destination $destination): Restrictions
    // ...

echo $restrictionResolver(new Destination('Portugal'));

Do you see what happened with $restrictionResolver? From the typed class method, nested in beautiful final class with the interface we got to... mixed.

We can use any of the following lines, and static analysis would silently pass:

echo $restrictionResolver('Portugal');
echo $restrictionResolver(new Destination('Portugal'));
echo $restrictionResolver(776);

How to Type a Callable?

What if we love defensive programming? We promote the closure back to the class method with type declarations and use the RestrictionResolverInterface interface to type it in place of use:

/** @var RestrictionResolverInterface $restrictionResolver */

Here the PHPStan knows everything and has our back

But how do we get the same luxury with callables? We can use docblock to define the types explicitly. We just move class method declaration from the very start to a @var format:

/** @var callable(Destination $destination): Restrictions $restrictionResolver */

We have defined parameter types and a return type.

You can read more in PHPStan docs:

It's almost identical to PHP type declaration syntax, except union type has to be wrapped in brackets ():

Now we know how to type callables in our code. Just image it's a class method and write the types:

/** @var callable(<paramNamesWithTypes>): <returnType> $<variableName> */

In our case:

/** @var callable(Destination $destination): Restrictions $restrictionResolver */

But how do we detect this in CI before merging them in our code base?

Detect Mixed Callables in 2 Steps

  1. Add new rule from symplify/phpstan-rules:
# phpstan.neon
    - Symplify\PHPStanRules\Rules\Explicit\NoMixedCallableRule

Update: Ondra shared with me, that you can also use parameter for similar check outside levels:

    checkMissingCallableSignature: true
  1. Run PHPStan:

That's it!

To make the type as strict as possible, we always have to type all the places with callable:

Then your PHPStan will see much more than before, making your code even safer to work with!

Happy coding!

Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!