PHPStan at Level max: Strategies for a Growing Codebase
AI generated
<?php
8.4
PHP · PHPStan · Static Analysis · CI/CD
PHPStan Level max: Strategies for a Growing Codebase
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.

17 min read Level ratcheting · Baseline · Generics · Custom Rules PHP 8.4 · Composer · CI/CD

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?
An alias for the highest available rule level of a PHPStan version, currently identical to level 9, potentially extended with future releases.
2Why not jump directly to the highest stage?
A grown project typically gets thousands of error reports at once. Level ratcheting with gradual increases is more practical.
3What does a phpstan-baseline.neon do?
Freezes existing errors with pattern, path and count. New errors in the same code stand out immediately, known legacy stays documented.
4How do I keep the baseline up to date?
Regenerate regularly with --generate-baseline and review the diff in the pull request to spot growth immediately.
5What do @template and generics in PHPDoc provide?
They let PHPStan track type parameters through generic classes, even though PHP itself has no native generics.
6What is an array shape?
A PHPDoc syntax like array{name: string, age: int} that describes exactly the keys and value types of an associative array.
7What does treatPhpDocTypesAsCertain do?
Controls whether PHPDoc types are treated as absolutely reliable (default) or require additional runtime checks.
8How do I write a custom PHPStan rule?
Via the interface PHPStan\Rules\Rule with getNodeType() and processNode(). Registration via services in phpstan.neon, tests via RuleTestCase.
9How do I integrate the highest analysis stage into CI?
Fixed Composer script, CI cache for the result cache directory, build fails only on errors outside the baseline.
10How long does the path to the maximum stage take?
Depends on project size and code quality, usually a few weeks to a few months of parallel ongoing development with level ratcheting and a baseline.

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