Static Analysis and Refactoring
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against PHP 8.5, PHPStan 2.2, Psalm 6 and Rector 2.6, checked on 2026-10-05; the output shown was produced by PHPStan 2.2.17 and Rector 2.6.7. PHP checks types only when code runs. Static analysers read the code without running it and report type errors, impossible conditions, undefined methods, dead code and missing null checks — the bugs that otherwise reach production. Rector goes further and rewrites code automatically.
PHPStan
PHPStan analyses a code base at a chosen rule level, from 0 (basic checks: unknown classes, functions and methods)
to 10 (everything, including strictness about mixed). New projects start at a high level; existing projects raise
the level gradually, often with a baseline that records today’s errors so that only new ones fail the build.
| Level | Adds |
|---|---|
0 |
unknown classes, functions, methods called on |
1-2 |
possibly undefined variables, unknown methods on all expressions, PHPDoc validation |
3-4 |
return types, property types, dead code, always-true/false conditions |
5 |
argument types of method and function calls |
6 |
missing type declarations (typehints) |
7 |
union types: every member must support the operation |
8 |
nullability: calling methods on possibly |
9 |
strictness with explicit |
10 |
strictness with implicit |
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src --level=9
vendor/bin/phpstan analyse --generate-baseline # record existing errors in phpstan-baseline.neon
# phpstan.neon
parameters:
level: 9
paths:
- src
# includes:
# - phpstan-baseline.neon
Source: PHPStan — Rule Levels and PHPStan — The Baseline
What it finds
Given this code, which runs without errors in many cases:
<?php
declare(strict_types=1);
namespace Library;
final readonly class Book
{
public function __construct(public string $title, public int $year) {}
}
final class Report
{
/**
* @param array{title: string, year: int} $row
*/
public static function fromRow(array $row): Book
{
return new Book($row['title'], $row['yaer']); // typo in the key
}
/** @param Shelf<Book> $shelf */
public static function firstTitle(Shelf $shelf): string
{
return $shelf->first()->title; // first() may return null
}
public static function count(array $titles): int
{
return count($titles) . ' titles'; // returns a string
}
}
PHPStan at level 9 reports four errors:
$ vendor/bin/phpstan analyse --no-progress
------ -----------------------------------------------------------------------
Line Report.php
------ -----------------------------------------------------------------------
19 Offset 'yaer' does not exist on array{title: string, year: int}.
🪪 offsetAccess.notFound
25 Cannot access property $title on Library\Book|null.
🪪 property.nonObject
28 Method Library\Report::count() has parameter $titles with no value
type specified in iterable type array.
🪪 missingType.iterableValue
30 Method Library\Report::count() should return int but returns string.
🪪 return.type
------ -----------------------------------------------------------------------
[ERROR] Found 4 errors
Source: PHPStan — Getting Started
PHPDoc types and generics
PHP’s native types cannot express element types, shapes or generics, so analysers read a richer type language from PHPDoc. Both PHPStan and Psalm understand it:
| Type | Meaning |
|---|---|
|
arrays by key and value type |
|
array shapes (optional keys with |
|
refined scalars |
|
class names and callable signatures |
|
generics |
|
tool-specific overrides that other tools ignore |
|
assertions and purity |
A generic collection, checked by PHPStan wherever it is used:
<?php
declare(strict_types=1);
namespace Library;
/**
* @template T of object
*/
final class Shelf
{
/** @var list<T> */
private array $items = [];
/** @param T $item */
public function add(object $item): void
{
$this->items[] = $item;
}
/** @return T|null */
public function first(): ?object
{
return $this->items[0] ?? null;
}
}
/** @var Shelf<\DateTimeImmutable> $returns */
$returns = new Shelf();
$returns->add(new \DateTimeImmutable('2026-10-05'));
echo $returns->first()?->format('Y-m-d'), "\n"; // PHPStan knows first() returns ?DateTimeImmutable
Why generics stay in PHPDoc
Native generics have been discussed for a decade: runtime-checked generics would be expensive in PHP’s engine, and
erased generics (checked only by tools) raise the question of what the engine should do with them. Successive RFC
drafts and research by the PHP Foundation have not produced an accepted design, so the ecosystem has standardised on
PHPDoc generics: PHPStan, Psalm and PhpStorm all support @template, and framework code (Laravel’s collections,
Doctrine’s repositories) is annotated with them.
Extensions and framework support
PHPStan understands framework magic through extensions: Larastan for Laravel (Eloquent models, facades, container),
phpstan-symfony, phpstan-doctrine, phpstan-phpunit, plus rule packages such as phpstan-strict-rules. Install
them with Composer and, with phpstan/extension-installer, they register themselves.
composer require --dev larastan/larastan # in a Laravel project
vendor/bin/phpstan analyse --memory-limit=1G
Source: PHPStan — Extension Library and Larastan
Psalm
Psalm is the other major analyser. It has the same goals and the same PHPDoc type language, with different
strengths: taint analysis (tracking untrusted input to dangerous sinks such as SQL queries or echo), a powerful
@psalm-assert system, and automatic fixes with psalter. Version 6 is the current stable line (7.0 is in release
candidate on the date of writing). Teams usually pick one analyser; running both is uncommon.
composer require --dev vimeo/psalm
vendor/bin/psalm --init # creates psalm.xml with an error level
vendor/bin/psalm --taint-analysis
Source: Psalm documentation and Psalm — Security Analysis
Rector: automated refactoring and upgrades
Rector parses code and applies rules that rewrite it: upgrading to a newer PHP version, adding type declarations inferred from code, removing dead code, migrating framework APIs (Laravel, Symfony, PHPUnit). It is the standard way to move a large code base forward, together with tests and a static analyser to confirm the result.
<?php
declare(strict_types=1);
// rector.php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/legacy'])
->withPhpSets(php85: true) // all upgrades up to PHP 8.5
->withPreparedSets(deadCode: true, typeDeclarations: true);
Given a PHP 7-style class:
<?php
namespace Library\Legacy;
class Loan
{
private $isbn;
private $days;
public function __construct(string $isbn, int $days = null)
{
$this->isbn = $isbn;
$this->days = $days;
}
public function describe()
{
return $this->isbn . ' for ' . $this->days . ' days';
}
public function status($code)
{
switch ($code) {
case 1:
return 'active';
case 2:
return 'returned';
default:
return 'unknown';
}
}
public function hasPrefix($isbn)
{
return strpos($isbn, '978') !== false;
}
}
vendor/bin/rector process --dry-run proposes:
- private $isbn;
- private $days;
-
- public function __construct(string $isbn, int $days = null)
+ public function __construct(private readonly string $isbn, private readonly ?int $days = null)
{
- $this->isbn = $isbn;
- $this->days = $days;
}
- public function describe()
+ public function describe(): string
...
- public function status($code)
+ public function status($code): string
{
- switch ($code) {
- case 1:
- return 'active';
- case 2:
- return 'returned';
- default:
- return 'unknown';
- }
+ return match ($code) {
+ 1 => 'active',
+ 2 => 'returned',
+ default => 'unknown',
+ };
}
- public function hasPrefix($isbn)
+ public function hasPrefix($isbn): bool
{
- return strpos($isbn, '978') !== false;
+ return str_contains($isbn, '978');
}
Applied rules:
* ClassPropertyAssignToConstructorPromotionRector
* StrContainsRector
* ChangeSwitchToMatchRector
* ReadOnlyPropertyRector
* BoolReturnTypeFromBooleanStrictReturnsRector
* StringReturnTypeFromStrictStringReturnsRector
Note how it also fixes the implicitly nullable int $days = null (deprecated in 8.4) into ?int. Review the diff,
run the tests, then apply it with vendor/bin/rector process. Laravel Shift and Rector’s framework rule sets automate
framework upgrades in the same way (see What’s Changed and
Upgrading).
Source: Rector documentation and Rector — Set Lists
Putting it in CI
A typical quality pipeline runs, on every pull request: the coding-style fixer in check mode, the static analyser at
the project’s level, the tests, and composer audit:
vendor/bin/php-cs-fixer fix --dry-run --diff # or vendor/bin/pint --test
vendor/bin/phpstan analyse --no-progress --error-format=github
vendor/bin/phpunit
composer audit
Coding style tools are described in PSR Standards and Coding Style, testing in Testing with PHPUnit and Pest.
Source: PHPStan — Output format
Where older books and tutorials are outdated
None of the books consulted for this section covers static analysis: PHPStan (2016), Psalm (2016) and Rector (2017)
appeared around or after their publication, and the PHPDoc type language (generics, array shapes, refined scalar types)
matured later. Books rely on runtime checks and code review for the kinds of bugs these tools find automatically.
PHPStan 2.0 (2024) added level 10 and stricter list handling; the PHPCompatibility sniffs for PHP_CodeSniffer
complement Rector by detecting code that will break on a newer PHP version.