Static Analysis
PHPStan reads your code without running it; pick a level you can reach, add the Symfony and Doctrine extensions, then climb.
vendor/bin/phpstan analyse --configuration phpstan.dist.neon --level 6 src testsStatic analysis means reading code without running it, to find the mistakes PHP would only discover on the line that fails. A static analyser parses every file, works out the type of every expression, and reports where those types cannot line up. PHPStan is the tool most Symfony projects use.
Levels
PHPStan grades its own strictness as a level. Levels run from 0 to 10 and are cumulative: level 5 checks argument types, level 6 reports missing type hints, level 8 insists you handle null, level 9 and 10 tighten mixed. Level 10 is the highest and was added in PHPStan 2.0. In config, level: max and level: 10 are the same check today; max follows future releases upward, the number stays put.
Teaching it the framework
Out of the box PHPStan knows PHP, not Symfony. Two extensions fix that. phpstan/phpstan-symfony reads the compiled container XML, so it can type what the container returns and check console command definitions. phpstan/phpstan-doctrine loads an object manager, so it can type repository results and inspect queries. phpstan/extension-installer registers both from Composer metadata; listing their extension.neon files under includes: by hand does exactly the same thing.
Climbing
Do not start at 10 on an existing project. Run --generate-baseline, which writes every current error into phpstan-baseline.neon so the build is green, then raise the level one step, fix what the step reports, and commit.
Example
// src/Service/InvoiceTotals.php
declare(strict_types=1);
namespace App\Service;
final class InvoiceTotals
{
/**
* The array shape in the docblock is what lets PHPStan check the body.
* Without it, $lines is array<mixed> and nothing inside can be verified.
*
* @param list<array{qty: int, unitPrice: int}> $lines
*/
public function totalInCents(array $lines, ?int $discountInCents): int
{
$sum = 0;
foreach ($lines as $line) {
$sum += $line['qty'] * $line['unitPrice'];
}
// PHPStan rejects "$sum - $discountInCents" because $discountInCents
// may be null. strict_types does not apply to operators, and PHP
// itself would quietly coerce null to 0 rather than raise a
// TypeError; the nullable operand is still what makes it wrong.
// The null coalescing operator is the fix, not a cast.
return $sum - ($discountInCents ?? 0);
}
}
// Called as:
// (new InvoiceTotals())->totalInCents([['qty' => 2, 'unitPrice' => 500]], null)
// Output:
// 1000When to use it
- Catch a method that can return null before it reaches a template as an undefined-variable error.
- Verify every console command's option and argument names against the code that reads them.
- Gate a pull request on a fixed PHPStan level so new code cannot be looser than existing code.
- Inventory an inherited codebase: generate a baseline and count what each level would report.
More examples
The project configuration file
PHPStan looks for phpstan.dist.neon (or phpstan.neon) in the project root. Everything under parameters is the analysis itself; everything under includes pulls in rules written by someone else. Keep the level on its own line, because raising it is a one-character commit that reviewers can see.
# phpstan.dist.neon
includes:
# With phpstan/extension-installer these two lines are unnecessary -
# it adds them from Composer metadata. Shown for recognition.
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
# The baseline is a list of errors to ignore for now.
- phpstan-baseline.neon
parameters:
level: 6
paths:
- src
- tests
symfony:
# The file name is built from the kernel class and the environment.
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
doctrine:
objectManagerLoader: tests/object-manager.phpGiving the Doctrine extension an entity manager
The Doctrine extension cannot read entity mappings from source alone, so it boots the application and asks for the real manager. This file runs during analysis, which is why it needs the autoloader and the environment loaded. If it throws, PHPStan reports a configuration error rather than an analysis error.
// tests/object-manager.php
declare(strict_types=1);
use App\Kernel;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Dotenv\Dotenv;
require __DIR__.'/../vendor/autoload.php';
(new Dotenv())->bootEnv(__DIR__.'/../.env');
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();
/** @var ManagerRegistry $registry */
$registry = $kernel->getContainer()->get('doctrine');
// Return the single manager. For several managers, return $registry instead.
return $registry->getManager();Running it in CI, in the right order
Reach for this the first time analysis passes locally and fails in CI. The usual cause is not the code but the missing var/cache directory, which is gitignored. --no-progress keeps the log readable; the exit code is what the job actually checks.
# .github/workflows/quality.yaml
name: Quality
on: [push, pull_request]
jobs:
phpstan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
- run: composer install --no-interaction --no-progress
# The container XML must exist before analysis starts.
- run: php bin/console cache:warmup --env=dev
- run: vendor/bin/phpstan analyse --no-progress
Discussion