Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
PHPTutorial

PHPStan tutorial: catch PHP bugs before production with levels

Install PHPStan 2.3, choose a rule level, adopt a baseline for legacy code and fail CI only on new errors, with real output from a worked PHP 8.5 example.

Cloudsoft Zone

8 min read

Developer in a Code for America jacket working on a laptop during a meetup, with other people seated around a room
Photograph: Juhan Sonin from Boston, USA / CC BY 2.0, via Wikimedia Commons
In this article

PHPStan finds bugs in PHP code without running it: null dereferences, wrong argument types, missing return types and dead branches. The fastest way to adopt it on an existing project is to install it as a dev dependency, pick a rule level your code nearly passes, record today's errors in a baseline and fail CI only on new ones. This tutorial walks through that workflow with real output from PHPStan 2.3.1, released on 8 October 2026.

Everything below was run on PHP 8.5.7. The commands and configuration keys come from the official PHPStan user guide, so they work the same in a framework project or a plain Composer package.

Why static analysis pays off in PHP

PHP checks types at runtime. A ?Invoice that is occasionally null works in every manual test and then fails in production on the one request where the record is missing. Tests catch some of these, but only on the paths you thought to test.

A static analyser reads every path. It knows that find() can return null, that number_format() expects an int for decimals, and that a method without a return type hides what callers receive. You get the warning in your editor or in CI, before a user sees an error page.

Install PHPStan and run your first analysis

Install it per project with Composer so every developer and CI job runs the same version:

Shell
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src tests --level 0

The getting-started guide states that PHPStan itself needs PHP 7.4 or newer to run. The code you analyse can target any version; you tell PHPStan which one with the phpVersion setting shown later.

Start at level 0. It covers basic checks: unknown classes and functions, unknown methods called on $this, wrong numbers of arguments and variables that are always undefined. If level 0 already reports hundreds of errors, fix those first, because they are usually genuine crashes waiting to happen.

A worked example: three bugs, three levels

Here is a small service with three realistic mistakes. The repository's find() method returns ?Invoice, and Invoice::$customerEmail is a ?string.

PHP
<?php

declare(strict_types=1);

namespace App;

final class InvoiceService
{
    public function __construct(private InvoiceRepository $repository)
    {
    }

    public function reminderSubject(int $id): string
    {
        $invoice = $this->repository->find($id); // returns ?Invoice

        return 'Invoice ' . $invoice->id . ' is due';
    }

    public function formatTotal(int $id): string
    {
        $invoice = $this->repository->find($id);
        if ($invoice === null) {
            return 'n/a';
        }

        return number_format($invoice->totalCents / 100, '2');
    }

    public function emailDomain(int $id)
    {
        $invoice = $this->repository->find($id);
        if ($invoice === null) {
            return null;
        }

        // customerEmail is declared as ?string
        return strtolower(substr($invoice->customerEmail, strpos($invoice->customerEmail, '@') + 1));
    }
}

Running PHPStan at increasing levels against this file produced the following on our machine:

LevelNew error reportedIdentifier
0None. The file is syntactically fine and all symbols exist.-
5Parameter #2 $decimals of function number_format expects int, string given.argument.type
6Method App\InvoiceService::emailDomain() has no return type specified.missingType.return
8Cannot access property $id on App\Invoice|null.property.nonObject
8Parameter #1 $haystack of function strpos expects string, string|null given (and the same for substr).argument.type

Levels are cumulative. Level 5 adds argument types, level 6 adds missing type declarations, and level 8 adds calls and property access on nullable types. The level 8 errors are the ones that turn into "Attempt to read property on null" warnings in production logs.

The fixed version below passes level 10, the strictest level, with no errors:

PHP
<?php

declare(strict_types=1);

namespace App;

final class InvoiceService
{
    public function __construct(private InvoiceRepository $repository)
    {
    }

    public function reminderSubject(int $id): string
    {
        $invoice = $this->repository->find($id)
            ?? throw new InvoiceNotFound("Invoice {$id} not found");

        return 'Invoice ' . $invoice->id . ' is due';
    }

    public function formatTotal(int $id): string
    {
        $invoice = $this->repository->find($id);
        if ($invoice === null) {
            return 'n/a';
        }

        return number_format($invoice->totalCents / 100, 2);
    }

    public function emailDomain(int $id): ?string
    {
        $email = $this->repository->find($id)?->customerEmail;
        if ($email === null || !str_contains($email, '@')) {
            return null;
        }

        return strtolower(substr($email, strpos($email, '@') + 1));
    }
}

Notice that none of the fixes silence PHPStan. Each one makes the code honest: the missing invoice becomes an explicit exception, the decimals argument becomes an integer, and the nullable email is checked before use.

Two engineers sitting side by side at desks with large monitors, one showing a terminal
Run PHPStan locally before pushing so errors are fixed while the change is still fresh.

Choosing a rule level

PHPStan has eleven levels, 0 to 10. Level 10 arrived with PHPStan 2.0 and treats implicit mixed (missing types) as strictly as explicit mixed. A short summary from the rule-levels page:

  • 0-2: unknown symbols, possibly undefined variables, unknown methods on any expression and PHPDoc validation.
  • 3-4: return types, property assignment types and basic dead code such as always-false instanceof checks.
  • 5-6: argument types, then missing type hints.
  • 7-8: partially wrong union types, then nullable access.
  • 9-10: strict rules for explicit and then implicit mixed.

For new projects, start at level 8 or higher. For legacy code, run each level in turn and stop at the highest one where the error count is manageable, then use a baseline for the rest. Avoid --level max in shared configuration: the documentation notes that it moves to any new highest level when you upgrade, which can flood a pull request with unrelated errors.

Write a phpstan.neon file

Put the settings in a configuration file so the CLI call stays short and everyone uses the same rules:

YAML
includes:
    - phpstan-baseline.neon

parameters:
    level: 8
    paths:
        - src
        - tests
    phpVersion:
        min: 80400
        max: 80599
    tmpDir: var/phpstan
    reportIgnoresWithoutComments: true
  • phpVersion accepts a single version ID or, since PHPStan 2.0, a min/max range. Use the range for libraries that support several PHP versions.
  • tmpDir keeps the result cache inside the project, which makes it easy to cache in CI.
  • reportIgnoresWithoutComments forces every inline ignore to carry a reason in parentheses and disallows the older @phpstan-ignore-line and @phpstan-ignore-next-line forms.

Adopt a baseline for legacy code

A baseline records the errors that exist today so PHPStan reports only new ones. Generate it with:

Shell
vendor/bin/phpstan analyse --generate-baseline

This writes phpstan-baseline.neon, which you include from your main configuration. Each entry stores the message, the identifier, the file and a count:

YAML
parameters:
	ignoreErrors:
		-
			message: '#^Cannot access property \$id on App\\Invoice\|null\.$#'
			identifier: property.nonObject
			count: 1
			path: src/InvoiceService.php

Two rules keep a baseline healthy. First, never regenerate it to absorb new errors; the PHPStan guide is explicit that its purpose is to shrink and eventually disappear. Second, leave reportUnmatchedIgnoredErrors on (it is the default), so when someone fixes a baselined error PHPStan tells them to delete the entry. The guide suggests baselines for a few dozen to a few hundred errors; with thousands, lower the level instead. For very large baselines you can write it as PHP by passing a filename ending in .php, which PHPStan parses faster than NEON.

Ignore errors precisely

Sometimes the analyser is wrong or a third-party API is badly typed. Ignore the specific identifier on the specific line, with a reason:

PHP
$rows = $legacyClient->fetch(); // @phpstan-ignore argument.type (vendor SDK declares array, returns iterable)

An ignore comment on its own line applies to the next line. To find the identifier, run PHPStan with the default table output, where it appears next to each message, or use the playground at phpstan.org/try. In configuration you can ignore by identifier plus path, which is far safer than the old regex-only approach because it cannot accidentally match unrelated messages.

Two women looking at a laptop screen showing code in a dark-themed editor
Ignore an error only with its identifier and a written reason that a reviewer can check.

Run PHPStan in CI

PHPStan includes a github error formatter that prints GitHub Actions annotations, so errors appear inline on the pull request diff:

YAML
name: static-analysis
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
      - run: vendor/bin/phpstan analyse --no-progress --error-format=github

Cache the var/phpstan directory between runs to reuse the result cache. On GitLab CI, the gitlab formatter produces a Code Quality report in the same way.

What changed in PHPStan 2.3

PHPStan 2.3.0 was released on 6 October 2026, followed by 2.3.1 two days later. The release notes headline performance: the maintainers report that analysing WordPress core dropped from about 60 seconds on 2.1.33 to under 8 seconds. The release also fixes 75 issues and adds opt-in bleeding-edge improvements to generics inference and unused-variable detection. Upgrading within the 2.x line is a composer update phpstan/phpstan, but run it on a branch first, because better inference can surface new errors.

Gotchas

  • Magic code needs extensions. Frameworks that resolve properties or methods at runtime need their PHPStan extension, or level 2 will report many false "unknown method" errors.
  • PHPDoc is trusted. By default treatPhpDocTypesAsCertain is true, so a wrong @return tag produces confident but wrong conclusions. Prefer native types.
  • Do not paper over errors. Casting, widening a type to mixed or adding an @var override makes the error disappear without fixing the bug.
  • Pin the version. Commit composer.lock so CI and laptops agree on the result.

What to do next

Install PHPStan today at level 0 and fix what it finds. Raise the level one step per sprint, generate a baseline at the level you want to hold, and add the CI job so new code meets that standard from the first pull request. Once the baseline is empty, move up another level.

Frequently asked questions

  • Which PHPStan level should I start with?

    On an existing project, start at level 0 and raise the level until the error count becomes unmanageable, then generate a baseline. New projects can usually start at level 8 or higher.

  • What is the highest PHPStan level?

    Level 10, added in PHPStan 2.0. It extends the strict mixed checks of level 9 to implicit mixed types, which come from missing type declarations.

  • Should I use --level max?

    Only if you accept new errors on every upgrade. max is an alias for the highest level, so a PHPStan release that adds a level moves you onto it automatically. Pin a number in phpstan.neon instead.

  • What is a PHPStan baseline?

    A file, phpstan-baseline.neon by default, that lists existing errors so PHPStan reports only new ones. Generate it with --generate-baseline, include it from your configuration and shrink it over time.

  • How do I ignore a single PHPStan error?

    Add // @phpstan-ignore identifier (reason) on the line or the line above. Setting reportIgnoresWithoutComments: true makes the reason mandatory.

  • Which PHP version does PHPStan need?

    PHPStan runs on PHP 7.4 or newer. The phpVersion setting tells it which PHP version your code targets, independent of the PHP running the analysis.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Getting Started — PHPStan
  2. Rule Levels — PHPStan
  3. The Baseline — PHPStan
  4. Ignoring Errors — PHPStan
  5. Config Reference — PHPStan
  6. PHPStan 2.3.0 release notes — GitHub (phpstan/phpstan), 2026-10-06

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.