Contributing

September 2, 2026 · View on GitHub

How the plugin works

The plugin boots a Laravel application, then hooks into Psalm's event system to override type inference for Laravel APIs.

The app is needed at boot time to read config values (e.g. auth.php guards), resolve facade aliases via AliasLoader, and load service providers. When run inside a Laravel project, the plugin loads the project's own bootstrap/app.php — so it sees the real config, routes, and providers. When no bootstrap/app.php is found (e.g. analyzing a Laravel package, or running the plugin's own tests), it falls back to Orchestra Testbench which provides a minimal Laravel app skeleton.

See ApplicationProvider::doGetApp() for the resolution logic.

flowchart TD
    A["Plugin::__invoke"] --> B["Parse PluginConfig from psalm.xml"]
    B --> C["Boot Laravel app\n(ApplicationProvider::bootApp)"]
    C --> D["Build migration schema\n(only if columnFallback=migrations)"]
    D --> E["Init facade→service map\n(FacadeMapProvider)"]
    E --> F["Init translation / view / env handlers\n(from booted app state)"]
    F --> G["Register handlers\n(Plugin::registerHandlers)"]
    G --> H["Register stubs\n(Plugin::registerStubs)"]

    H --- stubs["
        stubs/common/ (types + taint annotations)
        versioned dirs, ascending (e.g. stubs/12.42.0/, stubs/13.5.0/, stubs/13.8.0/)
        stubs/integrations/carbon/ (gated on installed nesbot/carbon version)
        aliases.phpstub (generated here from AliasLoader)
    "]

    I["Psalm scans all project files"] -.->|afterCodebasePopulated| J["ModelRegistrationHandler"]
    I -.->|afterCodebasePopulated| K["Eloquent Builder subclass fix-ups:\nBuilderSubclassQueryMixinHandler (restores dropped Query Builder @mixin)\nBuilderNativeStaticReturnTypeHandler (native ': static' return becomes docblock 'static')"]
    I -.->|afterCodebasePopulated| FMB["FactoryModelBindingHandler (injects @extends Factory<TModel> on bare factory subclasses, #780)"]
    I -.->|afterCodebasePopulated| FSP["FacadeStubPrecedenceHandler (drops conflicting facade @method pseudos when the plugin ships a real stubbed static method)"]
    I -.->|afterCodebasePopulated| FTF["FacadeTaintForwardingHandler (copies taint sinks from a facade's forwarding target onto its @method pseudo-methods)"]
    J --- models["
        Discover Model subclasses
        Register per-model property/method closures:
        relationship > factory > accessor > column
    "]

The whole __invoke body is wrapped in a try/catch: on any internal error the plugin reports a warning and disables itself for the run (or rethrows when failOnInternalError is set). See src/Internal/InternalErrorReporter.php.

Bootstrap failures are a special case: ApplicationProvider swallows a bootstrap() throw to keep the run alive (one bad config/*.php must not disable the plugin), so they never reach the try/catch above. Plugin::__invoke checks ApplicationProvider::getBootstrapError() right after boot and routes it through InternalErrorReporter::reportDegradedBoot(): a "degraded mode" warning by default, or escalation to the regular internal-error path when failOnInternalError is set (issue #1096). Note that Psalm's --no-progress flag installs a VoidProgress, which silences all Progress::warning() output, including these.

Getting started

git clone git@github.com:psalm/psalm-plugin-laravel.git
cd psalm-plugin-laravel
composer install
composer test        # lint + psalm + unit + type tests

Running tests

composer test          # full suite (lint + psalm + unit + type)
composer test:unit     # PHPUnit unit tests only
composer test:type     # type tests only (psalm-tester)
composer psalm         # self-analysis of plugin source
composer test:app      # creates a fresh Laravel project, scaffolds common class types (`make:xxx`), installs the plugin, and runs Psalm on the result
LARAVEL_INSTALLER_VERSION=12.12.2 composer test:app # run over a specific Laravel version

# single test file
./vendor/bin/phpunit tests/Unit/PluginConfigTest.php
./vendor/bin/phpunit --filter=AuthTest tests/Type/

Code style

  • PER Coding Style 3.0 (powered by php-cs-fixer: run composer cs to apply fixes)
  • Explain decisions and ideas in comments
composer cs     # auto-fix style issues
composer rector # run rector refactoring

How to add a stub

Stubs override Laravel's type signatures. Place them in:

  • stubs/common/ — shared across Laravel versions (includes both type stubs and taint annotations)
  • stubs/<version>/ — version-specific overrides, loaded when the installed Laravel is >= the dir name (version_compare). Both major-only (stubs/13/) and patch-level (stubs/13.8.0/) names work; currently stubs/12.42.0/, stubs/13/, stubs/13.5.0/, and stubs/13.8.0/ exist
  • stubs/integrations/<package>/ — optional stubs for third-party packages, gated on the package being installed (carbon/, with a pre-3.12/ subdir loaded only for older Carbon, see src/Stubs/CarbonStubProvider.php; and laravel-ai/, see below)

Rules:

  • Verify signatures against actual Laravel code (not against Laravel PHPDoc or method signatures)
  • Add a type test in tests/Type/tests/ to prevent regression
  • For taint annotations, see Taint Analysis Stubs

The laravel/ai integration gate

Four things load together for laravel/ai, all behind Plugin::laravelAiIntegrationEnabled() (the shared LaravelAiIntegration::isEnabled() check for >=0.11.0 <1.0.0), and they must stay in lockstep:

  1. stubs/integrations/laravel-ai/ via Plugin::optionalIntegrationStubs().
  2. Handlers\Ai\LlmOutputTaintHandler via Plugin::registerHandlers().
  3. Handlers\Ai\PromptGuardTaintHandler via Plugin::registerHandlers(), which exempts a prompt() / stream() call site whose agent middleware declares a guard annotated @psalm-taint-escape llm_prompt on whichever method Illuminate\Pipeline\Pipeline dispatches to it (__invoke for an object entry that has one, handle otherwise). Emission-time only (BeforeAddIssueInterface), stateless, so it needs no resetInvocationState() entry.
  4. Internal\PromptInjectionIssuePolicy via Plugin::__invoke(), which preserves Psalm's normal TaintedLlmPrompt error by default and applies a narrow D-in suppression only for <findPromptInjection value="false" />.

Part of that set is a silent half-integration: stubs without the handlers lose the property sources and the guard exemption, and the issue policy without the stubs could suppress a project's own llm_prompt annotations. Adding a fifth site means adding it to that method's callers, not writing a fifth copy of the version check.

Stub-versus-vendor signature drift is invisible to Psalm here (a stub-declared method wins over the reflected one), so bin/ci/check-laravel-ai-stub-parity.php diffs the two directly in CI, and tests/Unit/Ci/LaravelAiStubParityCheckerTest.php pins that script's own checks. A stub method added after the >=0.11.0 floor gets tagged @since X.Y.Z; the checker reads that tag and exempts the method for an older installed release instead of reporting it as drift.

Psalm otherwise merges a bare-redeclaration stub into the real class rather than replacing it: an omitted method/property keeps its real signature. The checker still flags a missing one, but as a taint-review tripwire (a merged-in method carries no taint annotations), not a correctness gate. The one thing genuinely erased is the interface list — implements — which the checker also diffs, per class, against the real one minus whatever the real parent already supplies.

Stub merging: how Psalm combines annotations

When multiple stub files declare the same method on the same class, Psalm reuses a single MethodStorage object and re-applies docblock parsing. The merging rules differ by annotation kind:

  • Type annotations (@return, @param): last-loaded file wins (direct assignment =)
  • Taint annotations (@psalm-taint-*): all files accumulate (bitwise OR |=)

This means splitting type and taint annotations for the same method across two stub files is fragile -- the type that "wins" depends on file loading order. Always put both in the same file.

When a class stub and a trait stub both declare the same method, Psalm creates separate MethodStorage objects -- one per class/trait. There is no cross-merging: if Connection.phpstub overrides a method defined in ManagesTransactions.phpstub, the trait's annotations (including taints) are ignored for that method. To keep both type and taint annotations, put them on the class stub.

Registration order (Plugin::registerStubs()): all common files, then version dirs ascending (array_merge). Since type annotations are last-loaded-wins, this order (not alphabetical path) decides overrides.

Version-specific overrides (conditional stub loading)

A file in a version dir (stubs/13.16.0/..., loaded when installed Laravel >= that version) overrides the same-named common file per method. Multiple version dirs cascade ascending: per method, the highest dir <= the installed version wins; a method it doesn't redeclare falls through to lower dirs, then common. (Verified: with common + 12.6.0 + 12.8.0 all declaring MessageBag::has, 12.8.0 won; missing() declared only in 12.6.0 survived; isEmpty() came from common.)

Authoring an override:

  • Declare only the changed methods; the rest merge from common.
  • Copy the full class header (extends/implements + use) verbatim, because a class re-declaration resets Psalm's interface list and silently strips contracts (see stub-authoring rules).
  • Types replace, taints accumulate (OR), so keep both for a method in one file.

Common vs version dir. Return narrowing that holds across all versions (Laravel only improved its annotation) goes in common. A parameter widened by behavior present only in a newer Laravel (e.g. firstOrNew's values taking \Closure|array only on 13) must go in the version dir: widening common would tell Psalm a call is valid that fatals at runtime on older versions (silent false negative).

Testing version-specific stubs

A type test that asserts a stubs/<version>/ override would fail on the lower cells of the CI matrix (.github/workflows/tests.yml runs test:type over Laravel ^13.3 and ^12.14), because the override does not load on the older Laravel. Gate such a test with a --SKIPIF-- section so it runs only where the stub applies:

--SKIPIF--
<?php
require getcwd() . '/vendor/autoload.php';
\Tests\Psalm\LaravelPlugin\Type\LaravelVersion::skipBelow('12.42.0');
--FILE--
... assertion of the version-specific behavior ...

LaravelVersion::skipBelow($version) skips when the installed Laravel is older than the stub dir (skipFrom($version) does the reverse for behavior only on older lines). The --SKIPIF-- script runs in a bare process from the project root, so it requires the autoloader via getcwd(). See tests/Type/tests/Http/PendingRequestTest.phpt for a worked example (the async HTTP client types are 12.42+).

How to add a handler

Handlers implement Psalm event interfaces to override type inference. Create the handler class in the appropriate src/Handlers/ subdirectory, then register it in Plugin::registerHandlers(). CollectionGroupByKeyByHandler specializes literal model attributes for collection groupBy() and keyBy() calls; unsupported forms defer to Laravel's stubs. Most taint handlers live under the Laravel feature directory whose API they cover (e.g. Handlers/Eloquent/WhereColumnTaintHandler); a stop-gap for an upstream Psalm bug that applies to every call site regardless of Laravel domain goes in Handlers/Taint/ instead (e.g. NamedArgumentTaintHandler, vimeo/psalm#11923).

Experimental issue lifecycle

Experimental status changes an issue's default severity, never whether its handler or type inference runs. Keep the experimental policy list in ExperimentalIssuePolicy as the single source of truth; PromptInjectionIssuePolicy is a separate, integration-gated opt-out for the stable TaintedLlmPrompt issue. Both policies delegate to Internal\DefaultIssueLevels, which implements the safe setter Psalm lacks: it applies a default only when the project has no handler for that issue, and recognizes its own earlier default by object identity so a later invocation can refresh it when the flag flips. Any explicit issue handler owns the complete reporting policy, including its base level and scoped filters.

  1. Introduce an experimental issue: register its handler normally, add its issue type to that internal list, and let the policy default it to info (or error when <experimental value="true" /> is configured).
  2. Graduate an issue: remove it from the internal list; it becomes a normal stable error.
  3. Withdraw an issue: remove its handler and issue class.

Do not add user-facing feature names or handler-registration gates. Do not overwrite or merge explicit issue handlers.

Psalm hooks used by the plugin

Psalm processes code in phases. Each hook fires at a specific phase and has different data available. Analysis hooks are hot paths — they fire on every matching expression. Scanning hooks fire once per class or once total. Source of truth for which handler implements which hook: Plugin::registerHandlers() plus each handler's implements clause.

flowchart TD
    subgraph P1["Phase 1 — Scanning (per class/trait/interface)"]
        A1["AfterClassLikeVisitInterface"]
    end

    subgraph P2["Phase 2 — Codebase populated (fires once)"]
        B1["AfterCodebasePopulatedInterface"]
    end

    subgraph P3["Phase 3 — Analysis (hot path, per file)"]
        direction TB
        C1["BeforeFileAnalysisInterface"] --> C2

        subgraph LOOP["repeats per statement / expression"]
            direction TB
            C2["BeforeStatementAnalysisInterface"] --> C3["BeforeExpressionAnalysisInterface"]
            C3 --> C4["Type/taint providers on the matched expression:
            FunctionReturnTypeProviderInterface
            MethodReturnTypeProviderInterface
            MethodParamsProviderInterface
            MethodExistenceProviderInterface
            MethodVisibilityProviderInterface
            property existence/type/visibility providers
            AddTaintsInterface / RemoveTaintsInterface"]
            C4 --> C5["AfterExpressionAnalysisInterface"]
            C5 --> C6["AfterMethodCallAnalysisInterface"]
        end

        C6 --> C7["AfterFunctionLikeAnalysisInterface"]
        C7 --> C8["AfterFileAnalysisInterface"]
    end

    subgraph P3B["Phase 3b — Taint graph resolved (main process, after the workers exit)"]
        E1["BeforeAddIssueInterface
        fires here for every taint issue,
        and inside the workers above for every type issue"]
    end

    subgraph P4["Phase 4 — Run complete (fires once)"]
        D1["AfterAnalysisInterface"]
    end

    P1 --> P2 --> P3 --> P3B --> P4

Registering handlers

There are two ways to register:

  1. Class-level (most handlers): implement the interface, register via $registration->registerHooksFromClass(MyHandler::class) in Plugin::registerHandlers()
  2. Closure-level (model property handlers): register via $providers->property_type_provider->registerClosure(...) — used by ModelRegistrationHandler to bind property handlers per-model after codebase is populated

Every class registration keeps the matching require_once beside registerHooksFromClass().

Exempting one call site from a stub's taint sink

Narrow the finding at emission time, not in the taint graph. ResponseFactoryTaintHandler is the reference shape: it implements only BeforeAddIssueInterface, matches the issue's journey tail against the sink's labels, re-checks the call site, and returns false to drop the finding. Graph level removal (RemoveTaintsInterface) cannot express "this call site only" and leaks onto shared flow edges; Architecture Decisions records why, along with the alternatives already ruled out.

Such a handler has to be stateless, because the analysis hooks run in the worker processes and taint issues are emitted only after those exit. Re-derive every fact from the issue plus Codebase::getStatementsForFile(), which reads the parser cache. The hook fires for every issue in the run, so bail on the issue class first and only then do anything expensive. Every uncertain path returns null and keeps the finding.

See Architecture Decisions for design rationale, Laravel Magic Call Patterns for how Laravel's __call/__callStatic chains work, Psalm Type Annotations for a quick reference of all supported types and annotations, and Debugging with Xdebug for stepping through handler code.

External resources