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.
8 min read

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:
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src tests --level 0The 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
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:
| Level | New error reported | Identifier |
|---|---|---|
| 0 | None. The file is syntactically fine and all symbols exist. | - |
| 5 | Parameter #2 $decimals of function number_format expects int, string given. | argument.type |
| 6 | Method App\InvoiceService::emailDomain() has no return type specified. | missingType.return |
| 8 | Cannot access property $id on App\Invoice|null. | property.nonObject |
| 8 | Parameter #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
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.

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
instanceofchecks. - 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:
includes:
- phpstan-baseline.neon
parameters:
level: 8
paths:
- src
- tests
phpVersion:
min: 80400
max: 80599
tmpDir: var/phpstan
reportIgnoresWithoutComments: truephpVersionaccepts a single version ID or, since PHPStan 2.0, amin/maxrange. Use the range for libraries that support several PHP versions.tmpDirkeeps the result cache inside the project, which makes it easy to cache in CI.reportIgnoresWithoutCommentsforces every inline ignore to carry a reason in parentheses and disallows the older@phpstan-ignore-lineand@phpstan-ignore-next-lineforms.
Adopt a baseline for legacy code
A baseline records the errors that exist today so PHPStan reports only new ones. Generate it with:
vendor/bin/phpstan analyse --generate-baselineThis writes phpstan-baseline.neon, which you include from your main configuration. Each entry stores the message, the identifier, the file and a count:
parameters:
ignoreErrors:
-
message: '#^Cannot access property \$id on App\\Invoice\|null\.$#'
identifier: property.nonObject
count: 1
path: src/InvoiceService.phpTwo 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:
$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.

Run PHPStan in CI
PHPStan includes a github error formatter that prints GitHub Actions annotations, so errors appear inline on the pull request diff:
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=githubCache 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
treatPhpDocTypesAsCertainis true, so a wrong@returntag produces confident but wrong conclusions. Prefer native types. - Do not paper over errors. Casting, widening a type to
mixedor adding an@varoverride makes the error disappear without fixing the bug. - Pin the version. Commit
composer.lockso 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
mixedchecks 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.
maxis an alias for the highest level, so a PHPStan release that adds a level moves you onto it automatically. Pin a number inphpstan.neoninstead.What is a PHPStan baseline?
A file,
phpstan-baseline.neonby 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. SettingreportIgnoresWithoutComments: truemakes the reason mandatory.Which PHP version does PHPStan need?
PHPStan runs on PHP 7.4 or newer. The
phpVersionsetting tells it which PHP version your code targets, independent of the PHP running the analysis.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Getting Started — PHPStan
- Rule Levels — PHPStan
- The Baseline — PHPStan
- Ignoring Errors — PHPStan
- Config Reference — PHPStan
- 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.
More from Cloudsoft Zone

Resource guidePHP
PHP 8.2 reaches end of life on 31 December 2026: an upgrade checklist
PHP 8.2 stops receiving security fixes on 31 December 2026. Here is the current support table from php.net, how to choose between PHP 8.4 and 8.5, and a step-by-step checklist for a safe upgrade.
By Cloudsoft Zone

TutorialPHP
Build a read-only PHP MCP server with MySQL and the official SDK
Expose product lookup, bounded low-stock searches and an inventory summary to AI clients with the official PHP MCP SDK, PDO and local STDIO transport.
By Cloudsoft Zone

Troubleshooting guideCodeIgniter
CodeIgniter 4 validation not working? 9 causes and fixes
Fix CodeIgniter 4 validation problems fast: wrong input source, lost errors after redirect, permit_empty, strict JSON rules, is_unique placeholders and more.
By Cloudsoft Zone
