The Ultimate Guide to PHP Static Analysis: PHPStan vs Psalm in 2026

PHPStan vs Psalm: Choosing a Static Analyser and Actually Adopting It

PHP’s type system checks what it can at runtime, which is both late and incomplete. A method returning ?User will happily hand back null to a caller that dereferences it immediately, and you find out when a customer does. Static analysers read the code without running it and catch that class of defect at commit time.

Two tools dominate: PHPStan and Psalm. They solve the same problem with different philosophies, and the choice matters less than most comparison articles suggest — what actually determines success is the adoption strategy. A team running PHPStan at level 2 with a growing baseline is in a far better position than one that installed Psalm at its strictest setting, drowned in 14,000 errors, and disabled it.

What Static Analysis Finds That Tests Do Not

Tests verify the paths you thought to write. Static analysis examines every path the type system can reach, including the ones nobody considered.

<?php

declare(strict_types=1);

final class InvoiceService
{
    public function findCustomer(int $id): ?Customer
    {
        return $this->repository->find($id);
    }

    public function totalFor(int $customerId): int
    {
        $customer = $this->findCustomer($customerId);

        // Both analysers flag this: calling a method on a possibly-null value.
        // A test only catches it with a fixture where the customer is missing.
        return $customer->invoices()->sum();
    }

    /** @param array<string,mixed> $row */
    public function fromRow(array $row): Invoice
    {
        // Flagged: $row['total'] is mixed, and the constructor wants int.
        return new Invoice($row['id'], $row['total']);
    }

    public function applyDiscount(Invoice $invoice, float $percent): void
    {
        if ($percent > 100) {
            throw new InvalidArgumentException('Discount cannot exceed 100%');
        }
        // Flagged as unreachable: the guard above already returned or threw
        // for every value that could satisfy this condition.
        if ($percent > 200) {
            $this->logger->alert('Impossible discount');
        }
    }
}

None of these needs a test to be provably wrong. That is the value proposition: a class of bug moves from “caught in staging if you are lucky” to “caught before the commit lands”.

The Two Level Systems

Both tools grade strictness, and the scales run in opposite directions — a detail that causes real confusion when a team switches.

PHPStan counts upward from 0. Level 0 catches undefined classes and functions. Level 5 checks argument types. Levels 8 and 9 tighten nullable and mixed handling, and the highest level requires every mixed value to be narrowed before use.

Psalm counts downward from 8 to 1, where 1 is strictest. Running psalm --init inspects the codebase and proposes a level it can pass at, which is a friendlier starting point than picking a number blind.

GoalPHPStanPsalm
Loosest useful checkLevel 0Level 8
Reasonable target for legacy codeLevel 4–5Level 5–6
Solid target for maintained codeLevel 8Level 3
StrictestmaxLevel 1
Config filephpstan.neonpsalm.xml
Suppress inline@phpstan-ignore-next-line@psalm-suppress IssueName

Psalm’s suppression is more precise, naming the specific issue rather than silencing a whole line. That granularity is genuinely useful on a large codebase, where a blanket ignore hides the second, real problem on the same line.

Generics and Array Shapes

PHP has no native generics, so both tools implement them through docblock annotations. This is where static analysis stops catching typos and starts catching design errors.

<?php

/**
 * A typed collection. The @template line declares a type variable that
 * both analysers track through every method.
 *
 * @template T of object
 */
final class TypedCollection implements IteratorAggregate
{
    /** @var list<T> */
    private array $items = [];

    /** @param class-string<T> $type */
    public function __construct(private readonly string $type) {}

    /** @param T $item */
    public function add(object $item): void
    {
        if (!$item instanceof $this->type) {
            throw new InvalidArgumentException('Wrong type for this collection');
        }
        $this->items[] = $item;
    }

    /**
     * @template TOut
     * @param  callable(T): TOut $fn
     * @return list<TOut>
     */
    public function map(callable $fn): array
    {
        return array_map($fn, $this->items);
    }

    /** @return Traversable<int,T> */
    public function getIterator(): Traversable
    {
        return new ArrayIterator($this->items);
    }
}

$users = new TypedCollection(User::class);   // inferred: TypedCollection<User>
$users->add(new User('ada'));
$users->add(new Invoice(1));                 // ERROR: expects User, got Invoice

// Inferred as list<string> without any annotation at the call site.
$names = $users->map(static fn(User $u): string => $u->name());

Array shapes are the other high-value annotation, and they earn their keep anywhere structured arrays cross a boundary — API payloads, database rows, configuration:

<?php

/**
 * @return array{
 *     id: int,
 *     email: non-empty-string,
 *     roles: list<'admin'|'editor'|'viewer'>,
 *     verified_at: ?string
 * }
 */
function fetchUserRow(PDO $db, int $id): array
{
    // ...
}

$row = fetchUserRow($db, 42);

echo strtoupper($row['email']);   // fine: known non-empty-string
echo $row['emial'];               // ERROR: key does not exist in the shape
echo $row['verified_at']->format('Y-m-d');  // ERROR: possibly null, and a string

foreach ($row['roles'] as $role) {
    // $role narrowed to the literal union - a typo'd comparison is flagged
    if ($role === 'administrator') {   // ERROR: never true for this union
        // ...
    }
}

The literal-union check catches a genuinely common bug: a string comparison that can never match because the value was renamed elsewhere. Where those values represent a fixed domain concept, promoting them from string unions to backed enums is usually the better fix — the reasoning is set out in the guide to PHP enums in domain-driven design.

Where They Genuinely Differ

Both tools handle the mainstream cases comparably. Three differences are substantive enough to drive a decision.

Psalm has taint analysis. It traces untrusted input from entry points to dangerous sinks and reports the path, which is closer to security tooling than type checking:

<?php

// Run with: vendor/bin/psalm --taint-analysis

final class SearchController
{
    public function search(): string
    {
        $term = $_GET['q'] ?? '';        // taint source

        // TaintedSql: $term flows into a query without parameterisation.
        $sql = "SELECT * FROM products WHERE name LIKE '%{$term}%'";
        $rows = $this->db->query($sql)->fetchAll();

        // TaintedHtml: the same value reaches output unescaped.
        return "<h1>Results for {$term}</h1>" . $this->render($rows);
    }
}

PHPStan has no first-party equivalent. For an application handling untrusted input at scale, this alone can justify running Psalm alongside whatever else you use.

Psalm can rewrite your code. Psalter applies fixes automatically — adding missing return types, removing unused imports, inferring docblocks across thousands of files:

# Add missing return types across the whole codebase, one issue at a time.
vendor/bin/psalm --alter --issues=MissingReturnType --dry-run
vendor/bin/psalm --alter --issues=MissingReturnType

# Then commit, review the diff, and move to the next issue class.
vendor/bin/psalm --alter --issues=MissingParamType,UnusedVariable

PHPStan has the deeper extension ecosystem. Framework-specific extensions teach the analyser about magic that reflection cannot see — Doctrine’s repository return types, Symfony’s container, Laravel’s facades and Eloquent models via Larastan. On a framework-heavy codebase this is the difference between useful output and a wall of false positives about methods that exist only at runtime.

CapabilityPHPStanPsalm
Generics and array shapesYesYes
Taint / security analysisNoYes
Automatic code fixingNoYes (Psalter)
Framework extensionsExtensiveFewer
Baseline supportYesYes
Language server / IDEVia pluginsBuilt in
Ecosystem momentumLargerSmaller

The Baseline: How Adoption Actually Survives

Point either tool at a mature codebase and it will report thousands of errors. Fixing them before merging is not viable, and shipping a red build teaches everyone to ignore it. The baseline solves this by recording every existing error and reporting only new ones.

# PHPStan: snapshot current errors, then start enforcing on new code.
vendor/bin/phpstan analyse --level=5 --generate-baseline

# Psalm: same idea, different flag.
vendor/bin/psalm --set-baseline=psalm-baseline.xml
parameters:
    level: 5
    paths:
        - src
        - tests
    # Errors recorded here are ignored. Anything new fails the build.
    includes:
        - phpstan-baseline.neon
    # Report entries that no longer match, so the baseline shrinks
    # as code gets fixed instead of quietly going stale.
    reportUnmatchedIgnoredErrors: true

That last setting is the one people skip and later regret. Without it, baseline entries linger after the underlying code is deleted or fixed, and the file grows into an unauditable record of problems that no longer exist.

The workflow that works on legacy code is incremental in two dimensions at once:

  1. Start at a level the codebase nearly passes — PHPStan level 2, or whatever psalm --init suggests.
  2. Generate a baseline and wire the analyser into CI as a required check.
  3. Fix baseline entries opportunistically, whenever you are already editing that file.
  4. When the baseline for a level empties, raise the level by one and generate a fresh baseline.

Every increment is a small, reviewable change, and the build never goes red for reasons unrelated to the commit. This pairs naturally with broader refactoring work — the sequencing is covered in the guide to modernising legacy PHP applications.

Wiring It Into CI

Analysis has to be a required check, not an advisory one. Anything a developer can merge past will eventually be merged past.

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.4'
          coverage: none          # analysis does not need Xdebug, and it is slow

      - uses: actions/cache@v4
        with:
          # Caching the result cache turns a 3-minute run into seconds
          # when only a few files changed.
          path: ./.phpstan-cache
          key: phpstan-${{ github.sha }}
          restore-keys: phpstan-

      - run: composer install --prefer-dist --no-progress
      - run: vendor/bin/phpstan analyse --error-format=github --no-progress

Two details pay for themselves. Disabling Xdebug removes a large constant factor from every run. Caching the analyser’s result cache means a pull request touching three files is analysed in seconds rather than re-checking the entire codebase.

Encoding Your Own Rules

Both tools let you write project-specific rules, which turns architectural conventions from wiki pages nobody reads into build failures. A common example: forbidding direct instantiation of date objects, because code that calls new DateTimeImmutable() internally cannot be tested at a fixed point in time.

<?php

declare(strict_types=1);

namespace App\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Expr\New_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * Flags direct date instantiation so time stays injectable.
 *
 * @implements Rule<New_>
 */
final class NoDirectDateTimeRule implements Rule
{
    /** Which AST node this rule wants to see. */
    public function getNodeType(): string
    {
        return New_::class;
    }

    /** @return list<\PHPStan\Rules\IdentifierRuleError> */
    public function processNode(Node $node, Scope $scope): array
    {
        // Dynamic class names (new $class) cannot be checked statically.
        if (!$node->class instanceof Node\Name) {
            return [];
        }

        $className = $node->class->toString();
        if (!in_array($className, ['DateTime', 'DateTimeImmutable'], true)) {
            return [];
        }

        // The clock implementation itself is allowed to construct dates.
        if (str_ends_with($scope->getClassReflection()?->getName() ?? '', 'Clock')) {
            return [];
        }

        return [
            RuleErrorBuilder::message(sprintf(
                'Instantiating %s directly makes this code untestable. '
                . 'Inject Psr\Clock\ClockInterface instead.',
                $className
            ))->identifier('app.directDateTime')->build(),
        ];
    }
}
services:
    -
        class: AppPHPStanNoDirectDateTimeRule
        tags:
            - phpstan.rules.rule

Rules like this are worth writing for the conventions your team keeps re-litigating in code review: no direct superglobal access outside the HTTP layer, no framework classes imported into the domain namespace, no @var annotations where a real type would work. Each one converts a recurring review comment into an automatic check, which is a better use of reviewer attention than repeating themselves.

Keep custom rules narrow and give each a clear identifier. A rule that fires on ambiguous cases trains developers to add suppressions reflexively, which is worse than not having the rule.

Which One

For most teams, PHPStan is the default. The extension ecosystem is the deciding factor: on a Symfony, Laravel, or Doctrine codebase, framework-aware extensions eliminate the false positives that otherwise make strict analysis unusable. The larger community also means faster answers when the analyser reports something you do not understand.

Choose Psalm when taint analysis matters — applications with a wide untrusted-input surface — or when Psalter’s bulk fixing would meaningfully shorten a large annotation effort. Its built-in language server is also a genuine advantage if your team’s editors are not already set up for PHPStan.

Running both is defensible but only in one configuration: PHPStan as the required CI gate, Psalm’s taint analysis as a separate scheduled security job. Running both as blocking checks means reconciling two sets of annotations and two suppression syntaxes for very little additional coverage.

The tool matters less than the discipline. Reference documentation for both is thorough — phpstan.org and psalm.dev — and either one, run at a modest level with a shrinking baseline and a required CI check, will catch more real defects in a month than an argument about which is stricter ever will.

editor's pick

latest video

news via inbox

Nulla turp dis cursus. Integer liberos  euismod pretium faucibua

Leave A Comment