phpstan/phpstan-symfony
PHPStan extension for Symfony that provides accurate return types for the container, controllers, requests, responses, forms, serializer, cache, and other framework APIs, helping catch bugs earlier and improving static analysis in Symfony projects.
This is a Symfony extension, not a Laravel one. In a pure Laravel app you already get container/app() resolution from Larastan; phpstan/phpstan-symfony earns its place when the codebase (or a package you maintain) contains real Symfony code: a Symfony bundle, a shared library that ships recipes for both frameworks, a Laravel app that leans on symfony/console, symfony/serializer, symfony/messenger, or symfony/http-foundation types directly, or a migration off Symfony.
Minimal setup:
composer require --dev phpstan/phpstan-symfony
# strongly recommended so you never hand-edit includes
composer require --dev phpstan/extension-installer
If you skip extension-installer, include vendor/phpstan/phpstan-symfony/extension.neon manually, and rules.neon as well if you want the framework-specific rules (private service access, unregistered services, console argument validation).
Look here first: parameters.symfony.containerXmlPath. Without it the container-typing features are inert. The extension reads the dumped DI container, so you must warm the cache before analysis:
bin/console cache:warmup --env=dev
vendor/bin/phpstan analyse
# phpstan.neon
parameters:
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
Note the file name drifts by Symfony version (srcDevDebugProjectContainer.xml → srcApp_KernelDevDebugContainer.xml → App_KernelDevDebugContainer.xml). Point at whatever your var/cache/dev actually produces.
First use case: make $container->get(Foo::class) and $this->get(Foo::class) inside AbstractController return Foo instead of object, so subsequent property/method calls are actually checked. On the Laravel side, the equivalent win is app(Foo::class) — that's Larastan's job. The second immediate win, and the one most Laravel devs will feel, is SerializerInterface::deserialize($json, Product::class) returning Product rather than mixed, and typed $input->getArgument() / $input->getOption() in console commands.
CI pipeline ordering. The container dump is an input artifact, not a source file. Always warm it in the job, then run PHPStan:
- run: php bin/console cache:warmup --env=dev
- run: vendor/bin/phpstan analyse --no-progress
Cache the var/cache/dev directory keyed on composer.lock + config/ if warmup is slow, but never commit it — a stale dump is worse than no dump.
Ramp up with a baseline, not a lower level. Turning on rules.neon on an existing app will flag every private-service fetch and every $container->get() of something you shouldn't be reaching for. Generate a baseline, then shrink it as you clean up:
includes:
- vendor/phpstan/phpstan-symfony/rules.neon
parameters:
level: 6
baseline: phpstan-baseline.neon
The "you're fetching a private service" diagnostic is genuinely valuable — in Laravel terms, it's the equivalent of catching app(SomeInternal::class) that only works because the binding happened to be public.
Typed console commands. Laravel's artisan commands have the same getArgument()/getOption() mixed problem. If the code in question is a Symfony Console command (including one registered inside a Laravel app as a custom Artisan command extending Symfony's Command), opt into real inference:
parameters:
symfony:
consoleApplicationLoader: tests/console-application.php
The loader must return an Application with the command registered. For single-command apps, add the command to a plain Application (see the README's SingleCommandApplication snippet). Now getOption('dry-run') is bool, and default values/undefined options are validated.
Query buses via HandleTrait. This is the marquee feature for CQRS-style code and maps directly onto Laravel's Bus::dispatch() habit. Wrapping HandleTrait::handle() normally degrades to mixed; declare the wrapper and the return type flows through the handler's __invoke return type:
parameters:
symfony:
messenger:
handleTraitWrappers:
- App\Bus\QueryBus::dispatch
- App\Bus\QueryBusInterface::dispatch
Interfaces work as well as concrete classes — useful when you type-hint QueryBusInterface in controllers. Add one entry per dispatcher method (dispatch, execute, ask, …).
PHP config files (Symfony 5.3+). If config/packages/*.php exists, you need both of these or the analysis silently misses services:
parameters:
scanDirectories:
- var/cache/dev/Symfony/Config
scanFiles:
- vendor/symfony/dependency-injection/Loader/Configurator/ContainerConfigurator.php
Pair with Larastan in mixed repos. Order doesn't matter much, but keep container config in separate neon files (phpstan-laravel.neon, phpstan-symfony.neon) and only include the Symfony one in projects that actually have a kernel + dumped container. A repo with both should set containerXmlPath only where it applies; there is no per-path conditional in PHPStan config.
Missing containerXmlPath fails quietly-ish. You get none of the container typing and no obvious error explaining why. First debugging step is always "is the XML path correct for this Symfony version, and was the cache warmed in this environment?"
Stale dumps produce phantom errors. Rename a service, forget cache:warmup, and PHPStan insists the old class exists and the new one doesn't. Clear var/cache/dev and PHPStan's tmpDir (result cache) when the container shape changes. Version 2.1.0 specifically added result-cache invalidation based on what the analysed code asks of the container — if you see stale container results on 2.0.x, upgrading is the fix.
PHPStan 2.3.0 is required for 2.1.x. Pinning phpstan/phpstan below that with phpstan-symfony: ^2.1 will resolve into a conflict or a broken analysis. Keep both in lockstep.
constantHassers: false is a sedative, not a fix. Setting it suppresses the "condition is always true/false" report for $this->has('service') on optional dependencies, but it also hides real dead branches. Use it only for the specific optional-dependency files, and prefer narrowing the type hint instead.
Private vs. unregistered services. Symfony 4.2+ made most services private; the extension reports get() on private services because it usually only works from the test container. In tests, prefer constructor injection or KernelTestCase::getContainer() — note 2.0.6 stopped narrowing KernelTestCase::getContainer() to the internal TestContainer, so don't pin below that if you have test assertions relying on the wider type.
PhpParser\NodeVisitor "already in use" compile error when using consoleApplicationLoader: the kernel's inline class loader collides with PHPStan's own PhpParser. Fix is a dedicated env, exactly as the README shows:
# config/packages/phpstan_env/parameters.yaml
parameters:
container.dumper.inline_class_loader: false
and boot new Kernel('phpstan_env', true) in the loader. This is the single most common blocker to enabling console inference.
Don't enable rules.neon without a valid container dump — the rules are container-driven and will produce noise rather than signal.
Env matters. dev containers include debug decorators and extra services; prod dumps differ. Analyse against the same env you'd debug in (dev is conventional) and keep it consistent between local and CI, or you'll get "works on my machine" PHPStan runs.
Laravel-only repos: don't bother. Without a Symfony kernel and container XML, the extension contributes only its class stubs — the Serializer, InputBag, ResponseHeaderBag, HeaderBag, Request::getContent() and Console type refinements. Those are real if you call those Symfony classes directly from Laravel (serializer-based API layers, custom response handling), but the container and rules.neon value is zero. Reach for Larastan for app()/resolve() typing, and add this one only if Symfony component classes actually appear in your stack.
Extension points are thin by design — this package is stubs + dynamic return type extensions + rules. If you need custom inference for your own service-locator wrapper, write your own DynamicMethodReturnTypeExtension and register it alongside; don't try to bend handleTraitWrappers to cover non-Messenger cases.
How can I help you explore Laravel packages today?