PHPStan in Practice: Adding Static Analysis to Your PHP Project

PHPStan is now used by 36% of PHP projects — a jump driven by modern frameworks, Drupal core's own adoption, and the realisation that type errors caught at analysis time cost nothing compared to type errors caught in production. This guide walks through a practical PHPStan setup from zero, including Drupal-specific extensions, baseline management, and CI integration.

Installation

composer require --dev \
    phpstan/phpstan \
    phpstan/extension-installer \
    phpstan/phpstan-deprecation-rules \
    phpstan/phpstan-strict-rules

For Drupal projects, also install the Drupal extension:

composer require --dev mglaman/phpstan-drupal

The extension-installer package automatically registers extensions. No manual includes: in your config file needed.

Configuration File

Create phpstan.neon in your project root:

parameters:
    level: 6
    paths:
        - web/modules/custom
    excludePaths:
        - web/modules/custom/*/tests/
    # Report unmatched ignored errors (clean up stale suppressions)
    reportUnmatchedIgnoredErrors: true
    # Treat all classes as potentially nullable unless proven otherwise
    checkMissingIterableValueType: false

For a Drupal project that needs to reference core and contrib types without analysing them:

parameters:
    level: 6
    paths:
        - web/modules/custom
    bootstrapFiles:
        - phpstan-drupal-bootstrap.php
    scanDirectories:
        - web/core
        - web/modules/contrib

Create phpstan-drupal-bootstrap.php:

<?php
// Bootstrap enough of Drupal for PHPStan to resolve service types
// and entity type manager calls.
define('DRUPAL_ROOT', __DIR__ . '/web');

PHPStan Levels Explained

LevelWhat it checks
0Unknown classes, undefined variables
1Possibly undefined variables, unknown magic methods
2Unknown methods on all expressions
3Return type checks
4Type checking, dead code (basic)
5Argument type checks
6Type strictness on return, missing typehints reported
7Reports partially wrong union types
8Null safety: nullable types checked in all expressions
9Strict mixed type checks — every mixed must be narrowed

Start at level 3 on an existing codebase and work up. For greenfield code, start at level 6 minimum.

Running PHPStan

# Analyse with config file
vendor/bin/phpstan analyse --memory-limit=512M

# Analyse a specific path, level 5
vendor/bin/phpstan analyse web/modules/custom/my_module -l 5

# Output in different formats
vendor/bin/phpstan analyse --error-format=github   # GitHub Actions annotations
vendor/bin/phpstan analyse --error-format=json     # Machine-readable
vendor/bin/phpstan analyse --error-format=table    # Default human-readable

The Baseline: Adopting PHPStan on Existing Code

Dropping PHPStan level 5 on a mature Drupal codebase can produce hundreds of errors. The baseline feature lets you capture all existing errors and only report new ones:

# Generate the baseline
vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon

# Include it in phpstan.neon
includes:
    - phpstan-baseline.neon

parameters:
    level: 6
    paths:
        - web/modules/custom

Commit phpstan-baseline.neon. From now on, the CI pipeline fails only when new errors are introduced. Chip away at the baseline errors in separate PRs. Drupal core adopted this exact strategy when moving to level 9.

Practical PHPDoc Annotations

PHPStan extends PHP's type system through PHPDoc. Common patterns in Drupal code:

<?php

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\node\NodeInterface;

class MyService
{
    public function __construct(
        private readonly EntityTypeManagerInterface $entityTypeManager,
    ) {}

    /**
     * @return \Drupal\node\NodeInterface[]
     */
    public function getPublishedNodes(): array
    {
        $storage = $this->entityTypeManager->getStorage('node');
        return $storage->loadByProperties([
            'status' => 1,
            'type'   => 'article',
        ]);
    }

    /**
     * @param array<string, mixed> $data
     * @return non-empty-string
     * @throws \InvalidArgumentException
     */
    public function buildKey(array $data): string
    {
        if (empty($data['id'])) {
            throw new \InvalidArgumentException('id is required');
        }
        return 'node:' . (string) $data['id'];
    }
}

Suppressing Individual Errors

When a false positive is unavoidable (common with Drupal's dynamic entity storage calls), suppress it inline:

<?php

// @phpstan-ignore-next-line
$value = $entity->get('field_custom')->value;

// Named ignore (PHPStan 1.10+, preferred)
// @phpstan-ignore property.nonObject
$value = $entity->get('field_custom')->value;

Avoid @phpstan-ignore-file — it silences an entire file and hides real errors.

Custom Rules for Drupal

The mglaman/phpstan-drupal extension adds Drupal-specific rules:

  • Detects use of deprecated Drupal APIs (with version warnings).
  • Validates that hook implementations match the expected signature.
  • Understands EntityTypeManager::getStorage() return types for known entity types.
  • Checks that t() / $this->t() arguments are string literals (required for translation extraction).
# With mglaman/phpstan-drupal installed via extension-installer,
# no additional configuration is needed. The extension auto-detects
# Drupal root and loads entity definitions.

Narrowing Types from Entity Storage

A common PHPStan pain point in Drupal: loadMultiple() returns array<string|int, \Drupal\Core\Entity\EntityInterface>. Narrow the type with an assertion:

<?php

use Drupal\node\NodeInterface;
use Webmozart\Assert\Assert;

$nodes = \Drupal::entityTypeManager()
    ->getStorage('node')
    ->loadMultiple($ids);

Assert::allIsInstanceOf($nodes, NodeInterface::class);
// PHPStan now knows $nodes is NodeInterface[]

Or use a typed helper in your service:

<?php

/**
 * @param int[] $ids
 * @return \Drupal\node\NodeInterface[]
 */
private function loadNodes(array $ids): array
{
    $nodes = $this->nodeStorage->loadMultiple($ids);
    // PHPStan trusts the return type declaration on this method.
    return array_filter($nodes, fn($n) => $n instanceof NodeInterface);
}

GitHub Actions Integration

name: PHPStan

on: [push, pull_request]

jobs:
  phpstan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --error-format=github --memory-limit=512M

The --error-format=github flag emits inline annotations directly in pull request diffs — no plugin required.

Raising the Level Incrementally

  1. Set level 3 and generate a baseline. CI is green.
  2. Fix the most impactful baseline errors (return type mismatches, null dereferences).
  3. Raise to level 5. Generate a new baseline for the remaining errors.
  4. Fix errors from new code only (CI enforces this).
  5. Schedule quarterly "baseline reduction" sessions to shrink the legacy debt.

Summary Checklist

  • Install phpstan/phpstan, phpstan/extension-installer, phpstan/phpstan-deprecation-rules.
  • Add mglaman/phpstan-drupal for Drupal codebases.
  • Start at level 3–5 on existing code; generate a baseline for existing errors.
  • Use --error-format=github in CI for inline PR annotations.
  • Use @return Type[], @param array<string, mixed> PHPDoc to narrow types PHPStan can't infer.
  • Use named ignores (@phpstan-ignore property.nonObject) over file-level suppressions.
  • Commit the baseline and treat its reduction as ongoing technical debt work.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.