Development
How to Add PHPStan to a Legacy PHP Codebase

Adding PHPStan to a legacy PHP codebase works best as a ratchet. Establish an honest record of existing findings, prevent new ones, and reduce the backlog as you touch each area.
Trying to fix thousands of findings before the tool reaches CI usually stalls adoption. Hiding whole directories or broad error patterns makes the check pass without giving the team useful protection. A baseline, a deliberate rule level, and a visible reduction policy provide a practical middle path.
Install it without changing production dependencies
Install PHPStan as a development dependency from the application root:
composer require --dev phpstan/phpstan
vendor/bin/phpstan --version
The analyzer runtime and the application target are separate concerns. The current PHPStan 2.x line requires PHP 7.4 or newer to run, as documented in the official getting-started guide. If the application still runs on an older PHP release, execute PHPStan in a dedicated supported tool container instead of raising production PHP merely to launch the analyzer.
When CI runs a newer PHP version than production, configure the PHP version being analyzed explicitly. This keeps PHPStan from accepting syntax or APIs that the deployed application cannot use. vendor/bin/phpstan diagnose reports both the analyzer runtime and the configured analysis target.
Commit composer.json and composer.lock. Run the same locked dependency set locally and in CI so results do not shift because two environments resolved different analyzer versions.
Start with a narrow path that contains code your team owns. Do not begin with generated files, cached templates, copied vendor libraries, or runtime uploads.
vendor/bin/phpstan analyse app public/index.php --level=1
PHPStan currently offers rule levels 0 through 10. Higher levels add stricter checks, but “highest immediately” is not the objective. The right first level produces actionable findings without forcing the team to encode guesses about poorly understood behavior. The PHPStan rule-level guide lists what each step adds.
Create a small, explicit configuration
Move the repeatable options into phpstan.neon:
parameters:
level: 3
phpVersion: 70400
paths:
- app
- public/index.php
tmpDir: var/phpstan
excludePaths:
analyse:
- tests/fixtures/*
- var/cache/*
The exclusions should describe files that are not source code. Do not exclude a difficult application directory merely because it has findings. That creates a permanent blind spot.
Run the configuration with:
vendor/bin/phpstan analyse
Inspect a sample from every major finding category before deciding what to do. Some errors reveal real defects. Others reveal incomplete type information at a framework or legacy boundary. Those need different treatments.
In this example, PHPStan itself may run under PHP 8.3 in CI while phpVersion: 70400 tells it to analyze the application as PHP 7.4. Use the real minimum supported by the product, and review the configuration reference when the supported range changes.
Improve type information at the boundaries
Legacy applications often return mixed arrays from database helpers, service containers, globals, and decoded JSON. PHPStan can only reason from the contracts it can see.
Add precise PHPDoc where the runtime contract is known:
<?php
declare(strict_types=1);
/**
* Loads a published article by its identifier.
*
* @return array{id:int, title:string, published_at:string}|null
*/
function findPublishedArticle(PDO $pdo, int $articleId): ?array
{
$statement = $pdo->prepare(
'SELECT id, title, published_at
FROM articles
WHERE id = :id AND status = :status'
);
$statement->execute([':id' => $articleId, ':status' => 'published']);
$article = $statement->fetch(PDO::FETCH_ASSOC);
if ($article === false) {
return null;
}
return [
'id' => (int) $article['id'],
'title' => (string) $article['title'],
'published_at' => (string) $article['published_at'],
];
}
The normalization is useful beyond static analysis. It stops database-driver string types and unstructured rows from leaking across the application.
Do not add a false return type just to silence the analyzer. If the value can genuinely be missing, model null and make the caller handle it. If a framework creates properties dynamically, prefer its official PHPStan extension or accurate stubs over declaring everything mixed.
Here is a deliberately small, illustrative finding rather than a claim about a measured project. A database boundary promises a numeric string, but the function promises an integer:
/**
* Returns a normalized invoice total.
*
* @param array{total:numeric-string} $row
*/
function invoiceTotal(array $row): int
{
return $row['total'];
}
PHPStan reports that the function should return int but returns numeric-string. Normalize at the boundary:
/**
* Returns a normalized invoice total.
*
* @param array{total:numeric-string} $row
*/
function invoiceTotal(array $row): int
{
return (int) $row['total'];
}
After the correction, run analysis without the baseline for this file. The finding should disappear. Regenerate the baseline narrowly and review the diff so the corresponding ignore entry is removed rather than replaced by a broader pattern.
Generate a baseline for known debt
Once the paths, level, extensions, and obvious configuration problems are correct, generate a baseline:
vendor/bin/phpstan analyse --generate-baseline
PHPStan writes the current accepted findings to phpstan-baseline.neon. Include it from the main configuration if the generated command has not done so:
includes:
- phpstan-baseline.neon
parameters:
level: 3
phpVersion: 70400
paths:
- app
- public/index.php
Commit the baseline. It is a debt inventory, not a success metric. Review it like code because an unexpectedly broad pattern can hide future errors. The official baseline guide explains generation and maintenance.
Use inline @phpstan-ignore annotations only when the exception is local, understood, and clearer beside the code. Include a reason when supported. Avoid configuration patterns such as “ignore every undefined method” across the application. That removes an entire class of protection.
Make CI reject new findings
A simple CI command is enough to start:
vendor/bin/phpstan analyse --no-progress --error-format=github
Use the format appropriate to your CI provider. Cache PHPStan's temporary directory only when the cache key includes the lockfile, PHP version, configuration, and relevant source state. A stale analysis cache should never be the reason a build passes.
Keep the initial job required once it is stable. If the job is optional for months, new debt can accumulate faster than the baseline shrinks.
Reduce the baseline while delivering normal work
Use a touched-code rule: when changing a class or function, resolve the nearby baseline findings that can be fixed without expanding the task dangerously. Remove the corresponding baseline entries in the same change.
A practical review sequence is:
- Run PHPStan with the baseline to ensure there are no new findings.
- Temporarily run the relevant path without the baseline.
- Fix well-understood findings in the touched area.
- Regenerate or edit the baseline narrowly.
- Confirm the baseline count did not grow.
Track the count over time, but do not reward a lower number achieved by broad ignores. The useful measure is known findings removed while analyzer coverage and rule strictness stay the same or improve.
Raise strictness deliberately
Do not combine a large baseline cleanup, dependency upgrade, and rule-level jump in one pull request. Each produces different failures and deserves its own review.
Raise the level when the current one is quiet enough that new findings remain visible. Preview the next level in a nonblocking CI job, group the findings by cause, improve shared types or stubs, then make it required.
PHPStan also supports the bleeding-edge configuration for changes planned for future releases. Treat it as an early-warning lane, not a surprise requirement on every legacy team.
Use one adoption gate
Treat the rollout as complete when one reviewable checklist is true:
- The locked PHPStan release runs on a supported tool runtime.
phpVersionmatches the application's deployed minimum or declared range.- All owned production code is analyzed; generated, cached, and vendor files are the only broad exclusions.
- Framework extensions, stubs, and PHPDoc describe real contracts rather than turning uncertain values into
mixed. - The committed baseline records existing debt, CI rejects new findings, and CI never grows the baseline automatically.
- Local and CI runs use the same lockfile, configuration, extensions, and rule level.
- Inline ignores are narrow and explained.
- Touched code removes nearby baseline entries without hiding a wider category.
- Runtime tests still cover business behavior that static analysis cannot prove.
From there, improve contracts at system boundaries and raise strictness one deliberate step at a time. The result is not merely a cleaner report. It is a legacy codebase that tells you more clearly when a proposed change is unsafe.