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
| Level | What it checks |
|---|---|
| 0 | Unknown classes, undefined variables |
| 1 | Possibly undefined variables, unknown magic methods |
| 2 | Unknown methods on all expressions |
| 3 | Return type checks |
| 4 | Type checking, dead code (basic) |
| 5 | Argument type checks |
| 6 | Type strictness on return, missing typehints reported |
| 7 | Reports partially wrong union types |
| 8 | Null safety: nullable types checked in all expressions |
| 9 | Strict 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
- Set level 3 and generate a baseline. CI is green.
- Fix the most impactful baseline errors (return type mismatches, null dereferences).
- Raise to level 5. Generate a new baseline for the remaining errors.
- Fix errors from new code only (CI enforces this).
- 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-drupalfor Drupal codebases. - Start at level 3–5 on existing code; generate a baseline for existing errors.
- Use
--error-format=githubin 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.