Contributing
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["Boot Laravel app"]
B --> C["Build schema\n(if columnFallback=migrations)"]
C --> D["Generate alias stubs\n(from AliasLoader)"]
D --> E["Register handlers"]
E --> F["Register stubs"]
E --- handlers["
Application — ContainerHandler, OffsetHandler
Auth — AuthHandler, GuardHandler, RequestHandler
Eloquent — ModelRegistrationHandler, RelationsMethodHandler,
ModelMethodHandler, BuilderScopeHandler
Console — CommandArgumentHandler
Helpers — CacheHandler, PathHandler, TransHandler
Rules — NoEnvOutsideConfigHandler
SuppressHandler
"]
F --- stubs["
stubs/common/
stubs/12/, stubs/13/ (version-specific)
stubs/taintAnalysis/
aliases.stubphp (generated)
"]
G["Psalm scans all project files"] -.->|afterCodebasePopulated| H["ModelRegistrationHandler"]
H --- models["
Discover Model subclasses
Register property handlers:
relationship > factory > accessor > column
"]
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.11.2 composer test:app # run over a specific Laravel version
# single test file
./vendor/bin/phpunit tests/Unit/Handlers/Auth/AuthHandlerTest.php
./vendor/bin/phpunit --filter=AuthTest tests/Type/
Code style
- PER Coding Style 3.0 (powered by php-cs-fixer: run
composer csto apply fixes) - Never use
@psalm-suppress— fix the issue or add topsalm-baseline.xml - 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 versionsstubs/12/,stubs/13/— version-specific overridesstubs/taintAnalysis/— security taint annotations (sources, sinks, escapes)
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
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().
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.
flowchart LR
subgraph scanning ["Phase 1: Scanning"]
direction TB
S1["AfterClassLikeVisitInterface
fires after each class/trait/interface
----
data: ClassLikeStorage
(direct parent only), AST statements
----
ContainerHandler
ModelMethodHandler
SuppressHandler"]
end
subgraph populated ["Phase 2: Codebase populated"]
direction TB
P1["AfterCodebasePopulatedInterface
fires once, after all classes are known
----
data: full Codebase, complete
ClassLikeStorage (full parent chain)
----
ModelRegistrationHandler
SuppressHandler"]
end
subgraph analysis ["Phase 3: Analysis (hot path)"]
direction TB
A1["FunctionReturnTypeProvider
on each global function call
----
args, call location, file path
----
CacheHandler, PathHandler
TransHandler
NoEnvOutsideConfigHandler"]
A2["MethodReturnTypeProvider
on each method call
----
args, class FQCN, method name
----
AuthHandler, GuardHandler
RequestHandler, ContainerHandler
CommandArgumentHandler
RelationsMethodHandler
ModelMethodHandler
BuilderScopeHandler, PathHandler"]
A3["MethodParamsProvider
before type-checking args
----
overrides parameter types
----
AuthHandler"]
A4["Property providers
Existence / Type / Visibility
----
property name, class FQCN
read/write context
----
Model property handlers
(registered via closures)"]
end
scanning --> populated --> analysis
Registering handlers
There are two ways to register:
- Class-level (most handlers): implement the interface, register via
$registration->registerHooksFromClass(MyHandler::class)inPlugin::registerHandlers() - Closure-level (model property handlers): register via
$providers->property_type_provider->registerClosure(...)— used byModelRegistrationHandlerto bind property handlers per-model after codebase is populated
See Architecture Decisions for design rationale and Debugging with Xdebug for stepping through handler code.