from level 0 to maximum strictness
PHPStan level max uncovers error classes that unit tests often miss: wrong argument types, risky null access, unreachable code. A growing project does not reach this level overnight, but through level ratcheting, a maintained baseline file, cleanly typed generics and custom rules for project-specific conventions.
Table of contents
- 1. What PHPStan level max actually means
- 2. Level ratcheting: stepping from level 0 to the maximum stage
- 3. Baseline files: freezing existing code without ignoring it
- 4. Generics and templates: @template, @extends and PHPDoc generics
- 5. Array shapes: precise types for associative arrays
- 6. Strictness configuration: the tuning knobs of the analysis
- 7. Writing custom rules: implementing the Rule interface
- 8. CI integration: enforcing strict analysis in the pipeline
- 9. PHPStan levels in direct comparison
- 10. Summary
- 11. FAQ
1. What PHPStan level max actually means
PHPStan knows rule levels from 0 to 9, each higher level enables additional, stricter checks on the same code. The alias level: max in the configuration does not stand for a fixed number, but for whichever is the highest level available in a given PHPStan version, currently identical to level 9, but potentially extended with new checks in every major release. Anyone who writes PHPStan level max into phpstan.neon is therefore not binding themselves to a static state, but to the continuously growing set of what the tool can detect in terms of type errors, impossible comparisons and risky access.
The practical value lies in finding the same class of errors that would otherwise only surface at runtime or in a code review: access on a possibly null value, method calls on objects of the wrong type, unreachable code after a return, or return values that do not match the declared signature. Unlike a unit test, PHPStan checks these cases statically, without executing the code, and therefore also covers paths that no test ever runs. This holds regardless of whether the project uses a framework like Symfony, its own micro-architecture, or, as in many agency setups, sits on top of a platform like Magento, PHPStan analyses plain PHP code, independent of the ecosystem above it.
The most common mistake when getting started is entering the highest stage directly into an existing, grown project. The result is typically several thousand reported errors at once, a list nobody seriously works through, which is why the analysis is usually ignored in practice or removed from the CI pipeline again. The following sections describe how a project reaches that target stage instead, step by step and without a loss of productivity.
2. Level ratcheting: stepping from level 0 to PHPStan level max
Level ratcheting describes the strategy of raising the PHPStan level not in one step, but through several small, planned increases, each one as its own commit or pull request. You start with a low level, typically 0 or 1, which only reports basic errors such as unknown classes or functions, fix the problems found, raise the level by one stage and repeat the process. The term ratchet aptly describes the principle: the level may only move in one direction, upward, never unintentionally fall back.
# phpstan.neon
parameters:
level: 6
paths:
- src
- tests
excludePaths:
- src/Legacy/*
tmpDir: var/cache/phpstan
includes:
- phpstan-baseline.neon
Every increase of the level value immediately shows how many new error reports the next stage causes, usually far fewer than the jump directly to the maximum stage would have been. A team can weigh this number against available capacity: if it is twenty new reports, they can be fixed in the same week, if it is two hundred, the uncritical cases first move into the baseline file, while real bugs are corrected immediately. That way the team always stays on a valid, green intermediate state instead of working on a giant construction site for weeks.
A productive pattern is to label the target level as its own CI check and schedule a fixed increase per quarter or sprint, for example from level 5 to 6, then to 7, until the target stage is reached. For new modules or freshly written code, a per-path configuration with a higher level than the rest of the project is also worthwhile, so new code follows the stricter standard from the start while legacy areas catch up step by step.
3. Baseline files: freezing existing code without ignoring it
A phpstan-baseline.neon, generated with vendor/bin/phpstan analyse --generate-baseline, freezes all error reports that exist at generation time as concrete ignoreErrors entries, each with a regex pattern, file path and expected count. The decisive difference to a blanket ignoreErrors entry without a baseline: every frozen error is documented exactly, in which file and how often it occurs. If the count changes, for example because a new error of the same kind is added, the analysis fails instead of silently swallowing the new error along with it.
# phpstan-baseline.neon
# Generated with: vendor/bin/phpstan analyse --generate-baseline
parameters:
ignoreErrors:
-
message: '#^Call to an undefined method App\\Legacy\\OrderExporter::mapStatus\(\)\.$#'
count: 3
path: src/Legacy/OrderExporter.php
-
message: '#^Method App\\Repository\\InvoiceRepository::findOpen\(\) return type has no value type specified in iterable type array\.$#'
count: 1
path: src/Repository/InvoiceRepository.php
The baseline makes it possible to enforce PHPStan level max immediately for new code, while the existing codebase does not have to be rewritten in a single massive effort. New errors in new or changed code stand out immediately, because they are not listed in the baseline, while known legacy issues stay documented but non-blocking. It is important to treat the baseline as technical debt that is visible and measurable, not as a permanent free pass.
In practice it pays off to regenerate the baseline regularly and review the diff in the pull request: if the file shrinks, real errors were fixed, if it grows unexpectedly, someone checked in new code without sufficient typing. For large projects, splitting the baseline into several files per module, included via separate includes entries, is worthwhile, so a team can work specifically on the baseline of its own area of responsibility without touching that of another team.
4. Generics and templates: @template, @extends and PHPDoc generics
Plain PHP still has no native generics at the language level to this day, PHPStan however fully compensates for this gap through PHPDoc annotations such as @template, @extends and @implements. A generic collection declares a placeholder type T via @template T at the class level, references it in property and method docblocks, and PHPStan tracks this type through the entire call chain, exactly like a real generic type system.
<?php
declare(strict_types=1);
namespace App\Collection;
/**
* Generic, type-safe collection of homogeneous items.
*
* @template T
*/
class TypedCollection implements \Countable, \IteratorAggregate
{
/** @var list<T> */
private array $items = [];
/**
* @param class-string<T> $itemClass Fully qualified class name items must be instances of
*/
public function __construct(private readonly string $itemClass)
{
}
/**
* @param T $item
*/
public function add(object $item): void
{
if (!$item instanceof $this->itemClass) {
throw new \InvalidArgumentException('Unexpected item type');
}
$this->items[] = $item;
}
/**
* @return T|null
*/
public function first(): ?object
{
return $this->items[0] ?? null;
}
public function count(): int
{
return count($this->items);
}
/**
* @return \ArrayIterator<int, T>
*/
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->items);
}
}
/**
* @extends TypedCollection<Invoice>
*/
final class InvoiceCollection extends TypedCollection
{
public function __construct()
{
parent::__construct(Invoice::class);
}
}
The annotation @extends TypedCollection<Invoice> binds T in InvoiceCollection concretely to Invoice, so that first() is recognized for this subclass as ?Invoice instead of the generic ?object. At the highest PHPStan stage, any call that passes the wrong class into add() or treats the result of first() as a concrete object without a null check is reported immediately, a safety net that would not exist at all without native generics in PHP.
For covariance, @template-covariant T is suitable when a generic type is only read, never written, this allows PHPStan to accept ReadOnlyCollection<Invoice> wherever ReadOnlyCollection<BillableItem> is expected, provided Invoice implements BillableItem. Modern IDEs read the same annotations and thereby offer correct autocompletion for generic return values, an additional productivity gain that goes far beyond pure error detection.
5. Array shapes: precise types for associative arrays
Associative arrays are ubiquitous in PHP, but practically invisible to PHPStan without additional type information: a plain array in the return type says nothing about which keys exist or what type the values have. Array shapes close this gap with a PHPDoc syntax like array{name: string, age: int, email?: string}, which describes exactly which keys an array has, what type each value has and which keys are optional, marked by the ? after the key name.
In addition there are list<T> for sequential arrays with guaranteed gapless integer keys starting at 0, and non-empty-array<T> for arrays that PHPStan treats as guaranteed non-empty, for example after an explicit check with count($array) > 0. This precision pays off especially at PHPStan level max, where generic array types without a value type are reported as an error anyway: instead of weakening every function with an unspecific array parameter, an array shape describes exactly the expected structure.
The practical benefit shows up especially with configuration arrays and API response structures: a typo in a key name, for example $config['databse'] instead of $config['database'], is caught immediately if the function expects an array shape with the correct key, an error that would otherwise only surface at runtime through a missing or null return value. The migration happens step by step: start with the most frequently called function signatures and add array shapes wherever the generic array type has so far obscured real type information.
6. Strictness configuration: the tuning knobs of PHPStan level max
The highest PHPStan stage automatically enables most strict checks, but some behaviors remain configurable through separate boolean parameters in phpstan.neon. checkMissingIterableValueType enforces that every iterable and array carries a value type, for example array<int, Invoice> instead of bare array, active by default from mid-range levels, but explicitly enforceable if a project wants to adopt it earlier. checkGenericClassInNonGenericObjectType reports when a generic class is referenced without type parameters, for example Collection instead of Collection<Invoice>, and thereby uncovers incompletely migrated generics annotations.
treatPhpDocTypesAsCertain controls whether PHPStan treats PHPDoc type declarations as absolutely reliable (default: true) or as uncertain additional information that could differ at runtime. Set to false, PHPStan requires additional runtime checks before relying on a PHPDoc type, useful in projects with historically unreliable or outdated docblocks, but also stricter and more effort to migrate. The package phpstan-strict-rules extends the built-in rules with additional checks not included in the core, for example a ban on implicit bool-to-string conversions or a requirement for explicit declare(strict_types=1) declarations in every file.
Other relevant knobs are checkUninitializedProperties, which reports typed properties without a default value and without guaranteed initialization in the constructor, as well as checkBenevolentUnionTypes, which flags implicit type coercions tolerated by PHP itself, for example an int that is silently continued to be used as a string. Each of these parameters can be enabled individually before reaching the target stage, to break down the migration into smaller, independently verifiable steps instead of introducing all tightenings at once.
7. Writing custom rules: implementing the Rule interface
Some project-specific conventions cannot be expressed with the rules shipped with PHPStan, for example forbidding certain function calls, requiring final classes in certain namespaces, or enforcing an internal naming scheme. For such cases you implement the interface PHPStan\Rules\Rule, which requires exactly two methods: getNodeType() returns for which node type of the underlying AST (provided by nikic/php-parser) the rule is invoked, processNode() receives the concrete node and the current Scope and returns a list of found errors.
<?php
declare(strict_types=1);
namespace App\PHPStan\Rules;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Forbids public methods without an explicit return type declaration.
*
* @implements Rule<ClassMethod>
*/
final class RequirePublicReturnTypeRule implements Rule
{
public function getNodeType(): string
{
return ClassMethod::class;
}
/**
* @param ClassMethod $node
* @return list<\PHPStan\Rules\IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if (!$node->isPublic() || $node->returnType !== null) {
return [];
}
return [
RuleErrorBuilder::message(sprintf(
'Public method %s() has no return type declaration.',
$node->name->toString()
))
->identifier('app.missingReturnType')
->build(),
];
}
}
Custom rules are registered via the services section in phpstan.neon, with the class name and optionally a tags: [phpstan.rules.rule] entry, and then run as a fully-fledged part of every analysis alongside the built-in checks of the highest stage. To validate custom rules, PHPStan provides the base class PHPStan\Testing\RuleTestCase, which checks test cases with expected error messages and line numbers against fixture files, so a custom rule is tested just as reliably as any other part of the codebase.
8. CI integration: enforcing PHPStan level max in the pipeline
The foundation of any CI integration is a fixed Composer dependency via composer require --dev phpstan/phpstan, complemented by phpstan/phpstan-strict-rules for additional strictness and, where applicable, extensions for the frameworks or test libraries in use. A composer.json script entry makes the call identical for all developers and the pipeline, regardless of whether it runs locally or in CI.
{
"scripts": {
"analyse": "phpstan analyse --memory-limit=1G",
"analyse:baseline": "phpstan analyse --generate-baseline=phpstan-baseline.neon",
"test": ["@analyse", "phpunit"]
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"phpstan/phpstan-strict-rules": "^1.6"
}
}
In the pipeline itself, the built-in result cache feature pays off: by default, PHPStan only re-analyses changed files on repeated runs, provided the cache folder persists between pipeline runs, for example via a CI cache key based on composer.lock. For large projects this reduces analysis time considerably, from several minutes down to a few seconds for unchanged files. For multi-core runners, --memory-limit combined with parallel process execution, which PHPStan handles by default, speeds up the analysis further.
A decisive point for CI integration is to let the build fail exclusively on new errors not contained in the baseline, not on every historical entry. This keeps the pipeline green for existing code, while every new pull request applies the same strict standard to new or changed code. A separate, non-blocking report job that tracks the number of baseline entries over time additionally makes visible whether technical debt is decreasing or increasing.
9. PHPStan levels in direct comparison
The following overview shows which error classes a project typically uncovers at which level, and serves as a rough guide for planning your own level ratcheting toward PHPStan level max.
| Level | What is typically checked | Recommendation |
|---|---|---|
| 0 | Syntax errors, unknown classes, functions and constants | Entry point for legacy code without any analysis history |
| 5 | Argument and return types checked roughly, simple type conflicts | Good starting point for actively maintained projects |
| 8 | Method calls on possibly null, stricter nullability checking |
De facto standard for new projects |
| 9 | Stricter type compatibility, mixed-type misuse, incomplete generics | For libraries with a public API |
| max | All available rules including the newest checks, finest array shape and generics checking | Goal for projects with high quality requirements |
The jump between the lower levels usually costs little effort, because mainly obvious errors such as unknown symbols are reported. The biggest migration effort usually lies between level 5 and 8, when nullability checks and stricter type compatibility kick in. The final step to the highest stage rarely adds fundamentally new error classes, but tightens existing checks and closes the last gaps in generics and array shapes, which is why it is often completed faster in a well-prepared project than the middle levels.
10. Summary
PHPStan level max is not a state you reach over a weekend, but the result of a structured process. Level ratcheting raises the level in small, manageable steps, instead of overwhelming the project with one single, massive pile of errors. The phpstan-baseline.neon freezes known existing code without ignoring it, and at the same time enforces that new code follows the strictest standard. Generics via @template and @extends as well as array shapes close type gaps that PHP itself cannot express, and are precisely what makes the strictest checks truly precise.
Strictness parameters such as checkMissingIterableValueType or treatPhpDocTypesAsCertain allow individual tightenings to be brought forward deliberately, instead of introducing everything at once. Custom rules via the Rule interface close the gap between generic static analysis and project-specific conventions. And a clean CI integration ensures that the target stage does not become a one-off exercise, but stays a permanent part of the development workflow, with baseline growth as a measurable metric for technical debt.
PHPStan level max for a growing codebase - The essentials at a glance
Level ratcheting
Raise the level step by step, each stage as its own commit, instead of jumping directly to the maximum stage.
Baseline strategy
phpstan-baseline.neon freezes existing code, new errors in new code stand out immediately, regenerate regularly.
Generics & array shapes
@template, @extends and array{key: type} close type gaps that PHP leaves open without native generics.
CI integration
Only fail the build on new errors, keep the result cache between runs, track baseline size as a metric.
11. FAQ: PHPStan Level max
1What is PHPStan level max?
2Why not jump directly to the highest stage?
3What does a phpstan-baseline.neon do?
4How do I keep the baseline up to date?
5What do @template and generics in PHPDoc provide?
6What is an array shape?
7What does treatPhpDocTypesAsCertain do?
8How do I write a custom PHPStan rule?
9How do I integrate the highest analysis stage into CI?
10How long does the path to the maximum stage take?
Mironsoft
Static analysis, code quality and CI pipelines for PHP projects
Is your project still stuck on a low PHPStan level?
We analyse your codebase, plan a realistic level ratcheting path to PHPStan level max, set up baseline files cleanly and integrate the analysis firmly into your CI pipeline.
PHPStan audit
Assessment of the current level and roadmap for level ratcheting
Baseline setup
phpstan-baseline.neon per module, generics and array shapes for critical core classes
CI integration
Result cache, baseline tracking and build rules that only trigger on genuinely new errors