Skip to content

Fix: map @property template types through @use and @extends - #12982

Open
alies-dev wants to merge 7 commits into
vimeo:masterfrom
alies-dev:alies-dev/cant-use-generic-type-in-magic-property-in-trait
Open

alies-dev wants to merge 7 commits into
vimeo:masterfrom
alies-dev:alies-dev/cant-use-generic-type-in-magic-property-in-trait

Conversation

@alies-dev

@alies-dev alies-dev commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Template types in @property/@property-write declared on a trait or parent class are not mapped through @use/@extends, and @property-write types are never localized to the receiver's type params.

Before

<?php
/**
 * @template T as string
 * @property T $status
 */
trait Status {}

class A {
    /** @use Status<"a"|"b"|"c"> */
    use Status;

    public function __get(string $name) {
        return 0;
    }
}

$a = new A();
$status = $a->status; // T:Status as string

/** @extends Box<int> */ // Box: @template T, @property T $value
class IntBox extends Box {}
$b = (new IntBox())->value; // T:Box as mixed

/** @param Box<int> $box */ // Box: @template T, @property-write T $value
function test(Box $box): void {
    $box->value = "a"; // InvalidPropertyAssignmentValue: $box->value with declared type 'T:Box as mixed' cannot be assigned type ''a''
}

After

$status = $a->status; // 'a'|'b'|'c'
$b = (new IntBox())->value; // int
    $box->value = "a"; // InvalidPropertyAssignmentValue: $box->value with declared type 'int' cannot be assigned type ''a''

Intersection receivers take the wrong class's type params

For I<string>&Box<int>, property types of Box (real and magic, read and write) were localized with I's type params.

Before

<?php
/** @template U */
interface I {}

/** @template T */
class Box {
    /** @var T */
    public $value;

    /** @param T $value */
    public function __construct($value) {
        $this->value = $value;
    }
}

/**
 * @param I<string>&Box<int> $box
 */
function test(I $box): void {
    $box->value = "a"; // no issue
}

After

    $box->value = "a"; // InvalidPropertyAssignmentValue: $box->value with declared type 'int' cannot be assigned type ''a''

Template intersection constraints are dropped through @extends/@use

Before

<?php
/**
 * @template T as object
 * @psalm-suppress MissingConstructor
 */
class Box {
    /** @var T&Countable */
    public $value;
}

class Payload {}

/**
 * @extends Box<Payload>
 * @psalm-suppress MissingConstructor
 */
class PayloadBox extends Box {}

function test(PayloadBox $box): void {
    $box->value = new Payload(); // no issue
}

After

    $box->value = new Payload(); // InvalidPropertyAssignmentValue: $box->value with declared type 'Payload&Countable' cannot be assigned type 'Payload'

Fixes #7766

…r that owns the property vimeo#7766

For `I<string>&Box<int>` the owning class was resolved to Box, but Box's templates were
substituted with I's type params. Take the type params of the intersection member that
matches the resolved class, on both the property write and read paths.
@alies-dev
alies-dev marked this pull request as ready for review November 1, 2026 10:09
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

✅ Performance: unchanged · Issues: decreased

Project Memory (MB) Wall (s) Issues Weighted
✅ laravel 1 907 $\color{gray}{\textsf{+0.1\%}}$ 117.2 $\color{gray}{\textsf{+3.1\%}}$ 30 841 $\color{red}{\textsf{+3}}$ $\color{green}{\textsf{-5}}$ 51 777 $\color{green}{\textsf{-4}}$
✅ symfony 3 378 132.2 $\color{gray}{\textsf{+2.9\%}}$ 52 855 88 405
✅ doctrine-orm 443 13.2 $\color{gray}{\textsf{+1.5\%}}$ 4 601 5 589
✅ psl 663 16.3 $\color{gray}{\textsf{-0.1\%}}$ 3 622 5 569
✅ phpunit 527 18.3 $\color{gray}{\textsf{+2.1\%}}$ 4 195 1 285
✅ php-parser 255 16.7 $\color{gray}{\textsf{+0.2\%}}$ 18 020 20 643
✅ amphp-amp 60 2.1 69 79
✅ webmozarts-assert 270 $\color{gray}{\textsf{+0.1\%}}$ 4.8 642 2 717
✅ guzzle-promises 55 2.1 $\color{gray}{\textsf{-0.5\%}}$ 203 407
✅ drupal 5 388 217.4 $\color{gray}{\textsf{+3.5\%}}$ 107 684 162 779
Total 12 945 540.3 $\color{gray}{\textsf{+2.9\%}}$ 222 732 $\color{red}{\textsf{+3}}$ $\color{green}{\textsf{-5}}$ 339 250 $\color{green}{\textsf{-4}}$
How to read this table
  • Memory: Psalm's own peak figure, threads=1. More than +5 % fails, unless the PR changes composer.json.
  • Wall: base and head run on the same runner; ±10 % is noise. Above +20 % warns, never fails. re-run: a reversed second run was taken and the faster kept.
  • Issues: head count, then $\color{red}{\textsf{+added}}$ $\color{green}{\textsf{-removed}}$ against base.
  • Weighted: issues weighted by error level (levels 1–8 as is, always-errors 10, feature-specific and purity suggestions 0). The projects' own code is pinned and their dependencies resolve identically for base and head, so a change means Psalm reports more, fewer or differently serious issues. A rise is not necessarily a regression.
  • Total: sum over the projects compared on both sides. Thresholds apply per project, not to the total.
  • Colors: $\color{red}{\textsf{red}}$ past a fail or warn threshold (or more issues), $\color{green}{\textsf{green}}$ an improvement past noise (or fewer issues), $\color{gray}{\textsf{gray}}$ noise.
  • Performance: regressed if a head run crashed or timed out or a memory or wall delta is red, else improved if one is green, else unchanged. Its icon is the check result: ❌ fail · ⚠️ warn · ✅ pass; the icon next to each project is that project's result, 💥 = not comparable: base crashed or timed out (counts as ⚠️).
  • Issues (heading): the total weighted change, increased · decreased · unchanged. Informational, never fails the check.
✅ laravel: issues +3 −5

Memory 1 906 → 1 907 MB (+0.1 %) · Wall 113.7 → 117.2 s (+3.1 %)

Weighted 51 781 → 51 777 (−4, −0.01 %): L1 −1 · L3 +1 · L4 −3 · L6 +1

Issue type added removed
MixedMethodCall 0 1
PossiblyInvalidMethodCall 1 0
PossiblyNullPropertyFetch 1 1
UndefinedMagicMethod 0 2
UndefinedMagicPropertyFetch 0 1
UndefinedMethod 1 0

Added (3)

  • src/Illuminate/Http/Resources/JsonApi/RelationResolver.php:55 PossiblyNullPropertyFetch (error): Cannot get property on possibly null variable $resolved->collection->map of type Illuminate\Support\HigherOrderCollectionProxy<string, mixed, Illuminate\Support\Collection>|null
  • src/Illuminate/Routing/Route.php:1169 PossiblyInvalidMethodCall (error): Cannot call method on possible array<array-key, mixed> variable
  • src/Illuminate/Routing/Route.php:1169 UndefinedMethod (error): Method Closure::flatten does not exist

Removed (5)

  • src/Illuminate/Database/DatabaseTransactionsManager.php:94 UndefinedMagicMethod (error): Magic method Illuminate\Support\HigherOrderCollectionProxy::executecallbacks does not exist
  • src/Illuminate/Http/Resources/JsonApi/RelationResolver.php:55 PossiblyNullPropertyFetch (error): Cannot get property on possibly null variable $resolved->collection->map of type Illuminate\Support\HigherOrderCollectionProxy<string, TValue, Illuminate\Support\Collection>|null
  • src/Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php:91 UndefinedMagicMethod (error): Magic method Illuminate\Support\HigherOrderCollectionProxy::getkey does not exist
  • src/Illuminate/Routing/Route.php:1156 UndefinedMagicPropertyFetch (error): Magic instance property Illuminate\Support\HigherOrderCollectionProxy::$middleware is not defined
  • src/Illuminate/Routing/Route.php:1169 MixedMethodCall (error): Cannot determine the type of the object on the left hand side of this expression

head 6b2a3a0 · base 84a5953 (merge-base) · workflow run

…e-generic-type-in-magic-property-in-trait

# Conflicts:
#	tests/Template/ClassTemplateExtendsTest.php
#	tests/Template/ClassTemplateTest.php
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant