← All results

Closure parameter and return bound through method templates

Group: PHPDoc generics · Category: phpdoc · File: generics_closure_param_from_method_template.php

A closure passed to a method of a generic class takes its parameter type from the class template, and a method-level template is bound from the closure's return: Collection<User>::map(fn ($u) => new Order()) is a Collection<Order>. References: - PHPStan generics by example (callable template binding) - Psalm templated callables

Analyzer results

AnalyzerVersionResultDiagnostics
phan 6.0.10Phan 6.0.10 php-ast version 1.1.3 PHP version used to run Phan: 8.5.11 Fail
generics_closure_param_from_method_template.php:90: Call to undeclared method \Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order::name [PhanUndeclaredMethod]
Expectation diff
Line 80: Expected 1 error(s)
phpstan 2.3.1PHPStan - PHP Static Analysis Tool 2.3.1 Pass reported Lv.2+
generics_closure_param_from_method_template.php:80: Call to an undefined method Conformance\Tests\GenericsClosureParamFromMethodTemplate\User::missing(). [reported-from-level=2]
generics_closure_param_from_method_template.php:80: Parameter #1 (mixed) of echo cannot be converted to string. [reported-from-level=10]
generics_closure_param_from_method_template.php:90: Call to an undefined method Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order::name(). [reported-from-level=2]
generics_closure_param_from_method_template.php:90: Parameter #1 (mixed) of echo cannot be converted to string. [reported-from-level=10]
With strict-rules
generics_closure_param_from_method_template.php:80: Call to an undefined method Conformance\Tests\GenericsClosureParamFromMethodTemplate\User::missing().
generics_closure_param_from_method_template.php:80: Parameter #1 (mixed) of echo cannot be converted to string.
generics_closure_param_from_method_template.php:90: Call to an undefined method Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order::name().
generics_closure_param_from_method_template.php:90: Parameter #1 (mixed) of echo cannot be converted to string.
psalm 6.20.0Psalm 6.20.0@804f03c0c183d149fcd4b8c7c0c9b3fbf836d34c
next: 7.0.0-rc2
Pass
generics_closure_param_from_method_template.php:80: Argument 1 of echo cannot be mixed, expecting string [MixedArgument]
generics_closure_param_from_method_template.php:80: Method Conformance\Tests\GenericsClosureParamFromMethodTemplate\User::missing does not exist [UndefinedMethod]
generics_closure_param_from_method_template.php:90: Argument 1 of echo cannot be mixed, expecting string [MixedArgument]
generics_closure_param_from_method_template.php:90: Method Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order::name does not exist [UndefinedMethod]
mago 1.57.0mago 1.57.0 Pass
generics_closure_param_from_method_template.php:80: Method `missing` does not exist on type `Conformance\Tests\GenericsClosureParamFromMethodTemplate\User`. [non-existent-method]
generics_closure_param_from_method_template.php:80: The first value for `echo` is too general. [mixed-argument]
generics_closure_param_from_method_template.php:90: Method `name` does not exist on type `Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order`. [non-existent-method]
generics_closure_param_from_method_template.php:90: The first value for `echo` is too general. [mixed-argument]
mir 0.87.0mir 0.87.0 Pass
generics_closure_param_from_method_template.php:80: UndefinedMethod: Method Conformance\Tests\GenericsClosureParamFromMethodTemplate\User::missing() does not exist [MIR0004]
generics_closure_param_from_method_template.php:90: UndefinedMethod: Method Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order::name() does not exist [MIR0004]
phpantom 0.11.1phpantom_lsp 0.11.1 Pass
generics_closure_param_from_method_template.php:80: Method 'missing' not found on class 'Conformance\Tests\GenericsClosureParamFromMethodTemplate\User' [unknown_member]
generics_closure_param_from_method_template.php:90: Method 'name' not found on class 'Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order' [unknown_member]
intelephense 1.18.5intelephense 1.18.5 Pass
generics_closure_param_from_method_template.php:80: Expected type 'string|Stringable|null'. Found 'mixed'. [P1006]
generics_closure_param_from_method_template.php:80: Undefined method 'missing'. [P1013]
generics_closure_param_from_method_template.php:90: Expected type 'string|Stringable|null'. Found 'mixed'. [P1006]
generics_closure_param_from_method_template.php:90: Undefined method 'name'. [P1013]
phpactor 2026.10.07.0Phpactor 2026.10.07.0 Fail

No diagnostics reported.

Expectation diff
Line 80: Expected 1 error(s)
Line 90: Expected 1 error(s)
phpy 1.0.19409phpy 1.0.19409 Pass
generics_closure_param_from_method_template.php:80: Call to unknown method: Conformance\Tests\GenericsClosureParamFromMethodTemplate\User::missing()
generics_closure_param_from_method_template.php:90: Call to unknown method: Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order::name()
qodana 262.10968.76PhpStorm 262.10968.76 Fail

No diagnostics reported.

Expectation diff
Line 80: Expected 1 error(s)
Line 90: Expected 1 error(s)
noverify 0.5.5NoVerify, version 0.5.5: built on: 2025.04.22 14:36:46 OS: r-kuchinskas arm64 Commit: 4774b82f8adc53fbef38f95e97af4b953b4d1529 Fail
generics_closure_param_from_method_template.php:44: param $items miss matched with phpdoc type <<list<T>>> [funcParamTypeMissMatch]
generics_closure_param_from_method_template.php:52: potentially not safe call in function Closure$(T):void signature of param closure param #0 [notSafeCall]
generics_closure_param_from_method_template.php:74: param $users miss matched with phpdoc type <<Collection<User>>> [funcParamTypeMissMatch]
generics_closure_param_from_method_template.php:85: param $users miss matched with phpdoc type <<Collection<User>>> [funcParamTypeMissMatch]
generics_closure_param_from_method_template.php:88: Call to undefined method {\Conformance\Tests\GenericsClosureParamFromMethodTemplate\T|mixed}->total() [undefinedMethod]
generics_closure_param_from_method_template.php:90: Call to undefined method {\Conformance\Tests\GenericsClosureParamFromMethodTemplate\T|mixed}->name() [undefinedMethod]
Expectation diff
Line 80: Expected 1 error(s)
Line 88: Expected valid value to be accepted, got ["Call to undefined method {\\Conformance\\Tests\\GenericsClosureParamFromMethodTemplate\\T|mixed}->total() [undefinedMethod]"]
Line 44: Unexpected errors ["param $items miss matched with phpdoc type <<list<T>>> [funcParamTypeMissMatch]"]
Line 52: Unexpected errors ["potentially not safe call in function Closure$(T):void signature of param closure param #0 [notSafeCall]"]
Line 74: Unexpected errors ["param $users miss matched with phpdoc type <<Collection<User>>> [funcParamTypeMissMatch]"]
Line 85: Unexpected errors ["param $users miss matched with phpdoc type <<Collection<User>>> [funcParamTypeMissMatch]"]
steins 0.1.8steins 0.1.8 (2026-09-21 revision 76306a9) Fail

No diagnostics reported.

Expectation diff
Line 80: Expected 1 error(s)
Line 90: Expected 1 error(s)
phpstan-strict 2.3.1PHPStan - PHP Static Analysis Tool 2.3.1 Pass
generics_closure_param_from_method_template.php:80: Call to an undefined method Conformance\Tests\GenericsClosureParamFromMethodTemplate\User::missing().
generics_closure_param_from_method_template.php:80: Parameter #1 (mixed) of echo cannot be converted to string.
generics_closure_param_from_method_template.php:90: Call to an undefined method Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order::name().
generics_closure_param_from_method_template.php:90: Parameter #1 (mixed) of echo cannot be converted to string.
psalm-next 7.0.0-rc2Psalm 7.0.0-rc2@20339e3fe5b8d03bcd8061030eb2a83ad50e5783 Pass
generics_closure_param_from_method_template.php:80: Argument 1 of echo cannot be mixed, expecting string [MixedArgument]
generics_closure_param_from_method_template.php:80: Method Conformance\Tests\GenericsClosureParamFromMethodTemplate\User::missing does not exist [UndefinedMethod]
generics_closure_param_from_method_template.php:90: Argument 1 of echo cannot be mixed, expecting string [MixedArgument]
generics_closure_param_from_method_template.php:90: Method Conformance\Tests\GenericsClosureParamFromMethodTemplate\Order::name does not exist [UndefinedMethod]

Source

<?php

declare(strict_types=1);

namespace Conformance\Tests\GenericsClosureParamFromMethodTemplate;

/**
 * Closure parameter and return bound through method templates.
 *
 * A closure passed to a method of a generic class takes its parameter type
 * from the class template, and a method-level template is bound from the
 * closure's return: `Collection<User>::map(fn ($u) => new Order())` is a
 * `Collection<Order>`.
 *
 * References:
 * - PHPStan generics by example (callable template binding)
 * - Psalm templated callables
 */

final class User
{
    public function name(): string
    {
        return 'user';
    }
}

final class Order
{
    public function __construct(public string $customer)
    {
    }

    public function total(): int
    {
        return 1;
    }
}

/** @template T */
final class Collection
{
    /** @param list<T> $items */
    public function __construct(private array $items)
    {
    }

    /** @param callable(T): void $callback */
    public function each(callable $callback): void
    {
        foreach ($this->items as $item) {
            $callback($item);
        }
    }

    /**
     * @template U
     * @param callable(T): U $callback
     * @return Collection<U>
     */
    public function map(callable $callback): Collection
    {
        return new Collection(array_map($callback, $this->items));
    }

    /** @return T */
    public function first(): mixed
    {
        return $this->items[0];
    }
}

/** @param Collection<User> $users */
function closureParameter(Collection $users): void
{
    $users->each(function ($user): void {
        echo $user->name(); // V
    });
    $users->each(function ($user): void {
        echo $user->missing(); // E: the closure parameter is a User
    });
}

/** @param Collection<User> $users */
function closureReturn(Collection $users): void
{
    $orders = $users->map(fn ($user) => new Order($user->name()));
    echo $orders->first()->total(); // V
    $mapped = $users->map(fn ($user) => new Order($user->name()));
    echo $mapped->first()->name(); // E: U is bound to Order, not User
}