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 $this, wrong argument counts

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 null values

9

strictness with explicit mixed

10

strictness with implicit mixed (missing types)

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

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

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

list<Book>, array<string, int>, non-empty-array<…>

arrays by key and value type

array{title: string, year: int, subtitle?: string}

array shapes (optional keys with ?)

non-empty-string, numeric-string, positive-int, int<1, 5>, literal-string

refined scalars

class-string<Book>, callable(string): int, Closure(Book): bool

class names and callable signatures

@template T, @template T of object, @extends Collection<Book>, @implements IteratorAggregate<int, Book>

generics

@phpstan-param, @phpstan-return, @psalm-…

tool-specific overrides that other tools ignore

@phpstan-assert, @phpstan-impure, @readonly

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

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

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).

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.

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.