Skip to content

[Bug]: self:: in an imported @phpstan-type alias is resolved against the importing class #113

Closed

Description

@fdaviddpt

Summary

A class defines a type alias that refers to its own constants through self:::

/** @phpstan-type Cell array{state: self::STATE_*, value: float} */
final class Ranking { public const STATE_FOUND = 'found'; ... }

Another class imports it with @phpstan-import-type Cell from Ranking. TypePHP resolves self against the importing class, so a valid value is rejected:

App\Search::cell(): Return value['state'] must be a valid constant matching App\Search::STATE_*, string 'found' returned

self in a @phpstan-type belongs to the class that declares the alias (that is how PHPStan reads it, and PHPStan level 9 reports nothing on the return here).

Playground

Open in playground — output: cell: App\Search::cell(): Return value['state'] must be a valid constant matching App\Search::STATE_*, string 'found' returned

<?php

declare(strict_types=1);

namespace App;

/**
 * @phpstan-type Cell array{state: self::STATE_*, value: float}
 */
final class Ranking
{
    public const STATE_FOUND = 'found';
    public const STATE_UNKNOWN = 'unknown';
}

/**
 * @phpstan-import-type Cell from Ranking
 */
final class Search
{
    /**
     * @return Cell
     */
    public function cell(): array
    {
        return ['state' => Ranking::STATE_FOUND, 'value' => 1.0];
    }
}

try {
    (new Search())->cell();
    echo "cell: OK\n";
} catch (\TypeError $e) {
    echo "cell: ", $e->getMessage(), "\n";
}

Actual

cell: App\Search::cell(): Return value['state'] must be a valid constant matching App\Search::STATE_*, string 'found' returned

Expected

cell: OK

Control

Declare the same alias in Search itself with the class named explicitly, @phpstan-type Cell array{state: Ranking::STATE_*, value: float}: the same return passes. So the constant check works; only self inside an imported alias is resolved in the wrong class. (Related in spirit to #93 and #111: names inside a type should be resolved in the scope that wrote them.)

Versions

v0.11.3 and dev-main @ 4e4244e (local project, PHP 8.3), and the playground.

Context

Our criteria-search module defines its matrix and ranking shapes once (@phpstan-type CriteriaMatrixCell array{state: self::CELL_STATE_ENUM_*, ...}, RankedRow with self::KIND_ENUM_* keys) and the commands that build them import those aliases; every matrix cell and ranked row is reported.

Activity

  1. self-assigned this
    on Oct 10, 2026
  2. rcalicdan commented on Oct 10, 2026

    @rcalicdan
    Member

    @fdaviddpt Hello, I had resolve all the your recent reported bugs in v0.11.4 patch released.

    Thank you again for reporting this important edge cases.. Don't hesitate to submit issues again and Hope TypePHP made your codebase better..

  3. fdaviddpt commented on Oct 10, 2026

    @fdaviddpt
    Author

    Thank you so much @rcalicdan! 🙏 That was fast: #109 to #113 all fixed in one patch release. We'll bump our CI job to v0.11.4 and rerun it on the modules that reported these, and I'll confirm here (or open a fresh issue if anything still bites).

    TypePHP has already paid for itself on our side. Running it over a big legacy codebase surfaced dozens of docblocks that described data the code never actually produced, things PHPStan level 9 had been trusting for years. Thanks for building it and for being this responsive. ❤️

    P.S. If you're curious, we open-source the developer tooling we build along the way (mostly Claude Code plugins) at https://github.com/Digital-Process-Tools. Feedback always welcome.

  4. rcalicdan commented on Oct 10, 2026

    @rcalicdan
    Member

    @fdaviddpt Thank you for the kind words, I really wanted to eliminate all docblock lies, and Im glad that it work's out on you too. btw how was the performance when typephp is running inside your codebase?

  5. fdaviddpt commented on Oct 10, 2026

    @fdaviddpt
    Author

    @rcalicdan Performance has been very reasonable for what it does. Our CI job runs each module's PHPUnit suite twice back to back, once without TypePHP and once with it in report mode, so we get a clean comparison (numbers from v0.11.0, property checks on):

    Module Tests Without TypePHP With TypePHP Time Memory
    Users 1010 13.9 s / 240 MB 19.1 s / 390 MB ×1.4 ×1.6
    Tags 938 7.6 s / 226 MB 13.2 s / 364 MB ×1.7 ×1.6

    So roughly +40–75% wall time and +60% memory on a full test suite. A fair price for checking every docblock at runtime, but more than we want to pay on every push. So the plan is to run it nightly rather than in every pipeline, a bit like how we treat our heavier PHPStan passes: a dedicated job, the JSON report as an artifact, and we go through the violations the next morning.

    For scale: our codebase is a long-lived PHP monolith, roughly 38k PHP files / 4.3M lines (about 700k of it generated from the DB schema), ~67k @param/@return/@var tags, analysed at PHPStan level 9. So TypePHP is checking a lot of docblocks written by a lot of hands over many years, which is exactly where the lies hide.

    One thing that made a big difference: caller and declared_in in the report (#105). Before, a violation told us what was wrong. Now it tells us which side to fix, the producer or the docblock, and that's most of the work.

    We're bumping to v0.11.4 now, and with #109 fixed we can finally install it as a regular dev dependency instead of having the job composer require it on the fly.

    Out of curiosity: does TypePHP cache the instrumented code / parsed docblocks between runs? We maintain warm-process MCP servers for PHPStan, Rector and PHPUnit (e.g. https://github.com/Digital-Process-Tools/mcp-phpstan-warm) and wondered whether something similar would make sense for TypePHP.

    Thanks again!

  6. rcalicdan commented on Oct 10, 2026

    @rcalicdan
    Member

    @fdaviddpt Thank you for the performance insights and this is really helpful for optimizing the performance more. As to answer the question if Typephp Cache instrumented code, Big yes. By default TypePHP caching is enabled and stored in temp folders in your OS if you don't specify the cache directory and it has lot of aggressive memoization of expensive operations to keep performance on single so fast . To make it run faster i recommend running vendor/bin/typephp cache:rebuild so it will clear the existing cache transformed files and rerun aot caching to improve performance.

  7. fdaviddpt commented on Oct 11, 2026

    @fdaviddpt
    Author

    Follow-up on 0.11.4: a full run over DVSI.

    Thanks for #109 to #113 in one release. With 0.11.4 we ran the whole codebase through TypePHP: 362 modules (every module that has unit tests), in batches of 20 to 80, in report mode with property checks on and no excludes besides the vendor dir.

    Result: no new TypePHP bug. Every violation group we triaged was on our side: a docblock that lied, or a test feeding a shape no real caller produces. That came to 13 merge requests of fixes in one night.

    Two patterns we did not expect, in case they are useful for your docs or examples:

    1. @phpstan-ignore hides docblock lies, and TypePHP finds them. Several violations sat right under an ignore line. One exporter declared array<string, mixed> while its only producer sends int-keyed header lists; two ignore lines in the caller had been silencing exactly that mismatch.
    2. Numeric-string array keys. A GA4 command builds $result[$formattedDate] with year keys like "2023". PHP casts them to int, so the declared array<string, int> can never hold for yearly aggregation. Static analysis accepted it, and only a runtime check shows it.

    One small thing, no issue filed: when code does include_once on a file that does not exist, PHP normally warns No such file or directory. With the stream wrapper active, the warning becomes "TypePHP\Internal\Io\StreamWrapper::stream_open" call failed, which hides the reason. In our case the bug was ours (our autoloader probing two files that do not exist), but the generic message made it look like a TypePHP problem at first. Passing the native error through, or naming the missing path in the message, would help. We could not reproduce it in the playground, since it does not run the file:// wrapper.

    Next on our side: moving TypePHP into require-dev with auto-boot: false, now that #109 makes that work with our custom vendor-dir. #114 (telling a run that checked nothing apart from a clean run) is the last thing we would like before running it nightly.

  8. fdaviddpt commented on Oct 11, 2026

    @fdaviddpt
    Author

    Numbers and method from that run, in case they help other teams adopting TypePHP on a large legacy codebase. (Correction to my count above: the timing data alone covers 370 modules, not 362.)

    Overhead on 0.11.4 (370 modules, PHP 8.3, PHPUnit 11, report mode, property checks on)

    Each module's suite runs twice in the same job: once without TypePHP, once with it, same DB, same runner.

    Machine: every batch ran on the same GitLab runner, a dedicated server with an Intel Xeon E-2136 (6 cores / 12 threads), 64 GB RAM and NVMe disks. Docker executor, PHP 8.3.31, MariaDB 10.5 as a service container. The runner takes up to 6 jobs at once and other pipelines shared it during the night, so per-module timings carry some noise; the baseline and TypePHP pass of a module run back to back, which keeps the ratio fair.

    Time Memory
    Whole run, summed ×1.85 (1,919 s → 3,556 s) ×1.64
    Per module, median ×1.76 (modules with a baseline ≥ 5 s, n=176) ×1.81
    Per module, p10 / p90 ×1.44 / ×1.91 ×1.10 / ×1.90

    One outlier: our documentation module went from 109 s / 1.3 GB to 388 s / 3.1 GB (×3.55 time, ×2.4 memory). Every other module stayed under ×2.4 memory. We have not looked into why yet; if a profile would help you, tell us what to capture.

    Violations: 365 distinct sites (after the report's dedup) in 99 of the 370 modules. The largest family came from our own code generator, not from hand-written code.

    How we ran it

    1. One manual CI job, never in composer.json yet. The job runs composer require typephp/typephp:0.11.4 itself, with opcache disabled for the Composer run (our CI php.ini otherwise left the autoloader without TypePHP, and every run read "0 violations").
    2. Batches of modules through an env variable. TYPEPHP_MODULES=A,B,C builds the include list from those module folders only. Batches of 20 work well; 80 is fine on a quiet runner.
    3. A probe before every module. A tiny class with @param int<0,max> gets called with -1. The log must print probe: CAUGHT, otherwise the run checked nothing and we discard it. This is the check Make a run that checks nothing distinguishable from a clean run #114 would make unnecessary.
    4. Baseline, then TypePHP, per module. Same tests twice. A failure in the TypePHP pass that also fails in the baseline is not TypePHP's.
    5. Report mode, one JSON per module as a job artifact. caller and declared_in ([Feature]: Report mode: caller frame, declaring docblock, merged reports #105) are what made triage fast.
    6. Triage by group, not by line. A script groups violations by kind + function + target + declared_in, counts occurrences, and shows the most common given values and callers. Known families we have decided to defer are filtered out by name, so each batch shows only new questions.
    7. A violation is a question, not an order. For each group we decide which side lies: the docblock or the code. Widening the docblock to whatever arrived is usually the wrong answer. A raw DB value gets cast where it is fetched; a test feeding a shape no real caller produces gets fixed or deleted; we widen only when a real caller sends the wider value, and we name that caller in the commit.
    8. One merge request per batch, reviewed by a human. Groups that need a product decision go in the MR description, not in code.

    The batches ran overnight, driven by our AI dev agent (Claude Code) with the loop above written down as a script; the humans review and merge the MRs in the morning. Fourteen batches covered the codebase in one night.

  9. rcalicdan commented on Oct 11, 2026

    @rcalicdan
    Member

    @fdaviddpt Thank you for this insights, glad the reporting mode is really helpful for AI agents..

  10. rcalicdan commented on Oct 11, 2026

    @rcalicdan
    Member

    One small thing, no issue filed: when code does include_once on a file that does not exist, PHP normally warns No such file or directory. With the stream wrapper active, the warning becomes "TypePHP\Internal\Io\StreamWrapper::stream_open" call failed, which hides the reason. In our case the bug was ours (our autoloader probing two files that do not exist), but the generic message made it look like a TypePHP problem at first. Passing the native error through, or naming the missing path in the message, would help. We could not reproduce it in the playground, since it does not run the file:// wrapper.

    I will look at it to reproduce on my side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions