
August 13, 2026
13 min read
By Kokil Thapa | Last reviewed: September 2026
Most teams stall at PHPStan level 5 because phpstan level 9 surfaces thousands of legacy violations at once. That stall costs you. Undefined array keys, nullable leaks, and untyped collections still reach production. In my experience maintaining Laravel and Symfony apps since 2010, max strictness only works with framework extensions, a baseline file, and CI rules that protect new code. This guide shows the exact config, workflow, and type-narrowing patterns that make Laravel API best practices genuinely type-safe without freezing feature work.
What makes phpstan level 9 different from lower PHPStan levels?
Level 9 is not just stricter syntax. It changes how PHPStan tracks type flow through your application. Levels 0–4 catch obvious mistakes like undefined variables. Level 9 demands proof at every branch.
At this level, bare array return types fail. You must declare array<string, int> or a typed collection. Every conditional path must return a consistent type. A function that returns string in one branch and null in another triggers an error unless the signature allows it.
Level 9 catches three bug classes that lower levels miss entirely:
- Collection type erosion: When you map an Eloquent collection, PHPStan verifies the closure return matches the declared generic. A docblock saying
@return Collection<User>fails if the closure can returnnull. - Unsafe array access: Reading
$array['key']without proof the key exists triggers an error. Prior validation or type narrowing must establish the key. - Exhaustiveness gaps: Match and switch on enums or unions must cover every case. Incomplete coverage becomes a hard error, not a silent fallthrough.
This strictness pays off on long-lived systems. On legal-tech portals like Court Marriage In Nepal, level 9 caught edge cases in attestation status handlers that manual review had missed for months. The upfront cost is real. The reduction in production debugging time justifies it for any codebase expected to survive beyond two years.
French-speaking teams searching for phpstan analyse statique php follow the same workflow. PHPStan is language-agnostic. Only your config files and error messages differ. The official PHPStan getting started guide documents level definitions in English regardless of locale.
How do you configure PHPStan level 9 for Laravel and Symfony?
Vanilla PHPStan cannot understand framework magic. Without extensions, level 9 flags every Facade call, dynamic relation, and container resolution as an error. Install framework adapters first.
Laravel with Larastan
For Laravel 13.x on PHP 8.3 or higher, use larastan/larastan 3.x. Laravel 12 projects on PHP 8.2 can use the same Larastan major version with adjusted framework constraints.
composer require --dev larastan/larastan:^3.0 phpstan/phpstan:^2.0 # phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 9
paths:
- app/
- config/
- routes/
excludePaths:
- app/Legacy/*
scanFiles:
- app/Support/helpers.php
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
reportUnmatchedIgnoredErrors: false The two check* flags enforce generic annotations that separate level 9 semantics from level 9 syntax alone. Without them, you run max level but skip the strictest iterable checks.
Pair this setup with Laravel feature testing best practices so runtime tests confirm what static analysis proves at compile time.
Symfony with phpstan-symfony
For Symfony 8.1 on PHP 8.4.1 or higher, install the Symfony and Doctrine extensions together:
composer require --dev phpstan/phpstan-symfony phpstan/phpstan-doctrine Symfony DI containers need the extension to resolve service types. Custom compiler passes often require stub files in a stubs/ directory referenced via scanDirectories. See the phpstan-symfony repository for extension-specific neon examples.
Symfony 7.4 LTS teams on PHP 8.2 follow the same pattern. Pin extension versions in composer.lock and run analysis inside CI with Composer 2.10.
Baseline management without losing momentum
Never fix all level 9 errors in one sprint. Generate a baseline, then enforce strictness on new and changed code only:
vendor/bin/phpstan analyse --generate-baseline=phpstan-baseline.neon Include the baseline in your neon config. Set reportUnmatchedIgnoredErrors: false during initial adoption so deleted legacy files do not break CI. Regenerate the baseline monthly to track debt reduction.
Wire analysis into your pipeline with GitLab CI/CD for PHP projects or GitHub Actions for Laravel testing and deploy. A typical job runs in under a minute on medium apps when you cache Composer dependencies.
# .gitlab-ci.yml excerpt
phpstan:
stage: test
script:
- composer install --no-interaction --prefer-dist
- vendor/bin/phpstan analyse --memory-limit=1G
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" On a recent e-commerce migration, baseline entries dropped from 1,400 to under 200 in six months. New features had to pass level 9 clean. Legacy refactors happened incrementally alongside normal tickets.
Which type-narrowing patterns satisfy PHPStan level 9 strictness?
Level 9 forces you to prove types explicitly. Three patterns handle most adoption errors.
Assert helpers for guard clauses
Reusable assertion functions narrow types for both PHPStan and runtime:
final class Assert
{
/** @throws InvalidArgumentException */
public static function nonNull(?object $value, string $msg = ''): object
{
if ($value === null) {
throw new InvalidArgumentException($msg ?: 'Expected non-null value');
}
return $value;
}
/**
* @template T
* @param array<T> $array
* @return non-empty-array<T>
*/
public static function nonEmptyArray(array $array, string $msg = ''): array
{
if ($array === []) {
throw new InvalidArgumentException($msg ?: 'Expected non-empty array');
}
return $array;
}
} After Assert::nonNull($user), PHPStan treats $user as non-null for the rest of the scope. This beats repetitive inline null checks.
Generic annotations for collections
Document collection contents precisely. Eloquent repositories should always declare the model type:
/**
* @return Collection<int, User>
*/
public function getActiveUsers(): Collection
{
return User::query()
->where('is_active', true)
->get();
} When transforming collections, chain the generic through the operation. If map() returns a different type, declare it explicitly. Level 9 validates closure return types against the declared output generic.
This pairs well with advanced Eloquent techniques where complex query builders need explicit return documentation.
Match expression exhaustiveness
Replace switch statements with match backed by enums. Always include a default that throws:
enum DocumentStatus: string
{
case Draft = 'draft';
case Pending = 'pending';
case Approved = 'approved';
}
function getStatusLabel(DocumentStatus $status): string
{
return match ($status) {
DocumentStatus::Draft => 'Draft',
DocumentStatus::Pending => 'Under Review',
DocumentStatus::Approved => 'Approved',
default => throw new LogicException("Unhandled status: {$status->value}"),
};
} The default throw satisfies exhaustiveness checks. It also surfaces a clear error when someone adds an enum case without updating the match. This pattern prevented production incidents on legal document workflows where status rules change frequently.
For bulk type declaration on legacy code, run PHP Rector for automated refactoring first. Then let PHPStan level 9 validate the result. Rector adds types; PHPStan proves they hold.
How does PHPStan level 9 compare to Psalm and Rector?
Each tool serves a distinct role. They complement each other rather than compete.
| Criteria | PHPStan Level 9 | Psalm (Max Strictness) | Rector |
|---|---|---|---|
| Primary focus | Bug detection via type flow | Type inference and taint analysis | Automated code transformation |
| Laravel support | Excellent via Larastan 3.x | Good via psalm-plugin-laravel | Moderate via rector-laravel |
| False positive rate | Low with proper extensions | Moderate with aggressive inference | N/A — transforms, does not validate |
| Config format | NEON | XML | PHP config with presets |
| CI speed (medium app) | Fast, roughly 30 seconds | Slower due to deeper inference | Slowest — AST rewriting |
| Best for | Runtime bug prevention gate | Security and taint tracking | Legacy modernization sprints |
I use PHPStan level 9 as the primary CI gate because error messages map directly to runtime failures. Psalm supplements security-sensitive modules like payment processing where taint analysis adds value. Rector handles bulk upgrades when moving from PHP 8.2 to 8.5 or adding return types to untyped legacy classes.
Combine static analysis with code coverage gates in CI and shift-left security in CI/CD pipelines for a complete pre-merge quality stack.
What are common pitfalls when adopting PHPStan level 9 in production?
Three mistakes derail most adoption attempts. Avoid them to keep team momentum.
- Enabling level 9 globally on day one. This floods the team with thousands of legacy errors. Start with a baseline. Enforce strictness only on new and modified files. Incremental adoption preserves velocity.
- Skipping framework extensions. Vanilla PHPStan on Laravel or Symfony produces noise that hides real bugs. Install Larastan or phpstan-symfony before writing a single ignore comment.
- Treating ignores as permanent. Every
@phpstan-ignoreor baseline entry needs a ticket or TODO with context. Schedule quarterly reviews to convert ignores into proper fixes.
Third-party packages without type declarations cause another subtle problem. Do not ignore entire vendor directories. Create stub files in stubs/ with minimal signatures for the methods you actually call. This keeps level 9 coverage in your code while acknowledging external gaps.
For developers evaluating hiring web developers in Nepal, familiarity with stub creation and baseline management signals genuine PHPStan experience. Superficial tool usage shows up quickly when legacy codebases resist level 9.
On production Laravel apps I maintain, PHPStan runs alongside Pest testing in CI/CD and Laravel Pint for coding standards. Static analysis catches logic bugs tests miss. Tests catch integration failures analysis cannot see.
How do you integrate PHPStan level 9 into a full quality workflow?
Static analysis alone does not ship reliable software. It belongs inside a broader quality stack that runs before every merge.
A practical pipeline for a Laravel 13 project looks like this:
- Pre-commit: Run Pint and a quick PHPStan pass on staged files via git hooks.
- CI merge request: Full PHPStan level 9 analysis, Pest test suite, and dependency vulnerability scan.
- Pre-deploy: Regenerate baseline if legacy fixes landed. Confirm baseline count did not increase.
- Post-deploy: Monitor error logs for type-related runtime failures PHPStan cannot predict, like external API shape changes.
For business-critical systems, engage a team that treats analysis as architecture, not an afterthought. Our testing and optimization services include static analysis setup, baseline strategy, and CI integration for Laravel and Symfony codebases.
Projects like Mijar Law Associates and Adventure Third Pole Trek benefit from strict typing because their booking and document workflows involve complex state machines. Level 9 catches branch gaps before they become support tickets.
Use the regex tester when writing custom PHPStan ignore patterns. Misconfigured regex in baseline files silently stop matching errors you intended to suppress.
PHP 8.5 readonly classes and property hooks reduce some annotation boilerplate. See PHP 8.4 property hooks practical guide for patterns that interact cleanly with static analysis. Stronger native types mean fewer docblock generics PHPStan must infer.
WordPress plugin teams can adopt incremental analysis too. See WordPress plugin development guide for where typed PHP fits alongside dynamic CMS APIs. Core WordPress 7.1 still mixes typed and untyped code, so baselines matter even more there.
Security-sensitive modules benefit from layering. Run PHPStan level 9 for logic bugs. Add OWASP Top 10 checks for PHP developers and optional Psalm taint analysis for input-to-sink tracking on auth and payment paths.
Key Takeaways
- PHPStan level 9 requires generic array types, exhaustive conditionals, and proven null safety — not just return type declarations.
- Install Larastan for Laravel or phpstan-symfony for Symfony before enabling max level; vanilla analysis produces unusable noise.
- Generate a baseline for legacy debt, enforce level 9 on all new code in CI, and regenerate the baseline monthly to shrink violations.
- Use assert helpers, typed collection docblocks, and exhaustive match expressions to satisfy strictness without excessive boilerplate.
- Pair PHPStan with Rector for legacy modernization and Psalm for taint analysis — keep level 9 as your primary runtime bug gate.
- Wire analysis into GitLab CI or GitHub Actions alongside Pest tests and Pint for a complete pre-merge quality stack.
People Also Ask
What PHPStan level should I start with?
Start at level 5 or 6 on existing codebases. Fix the most critical errors, then generate a baseline. Move to level 9 only after framework extensions are installed and CI enforces clean analysis on new files. Greenfield modules can target level 9 from the first commit.
How long does PHPStan level 9 take to run in CI?
A medium Laravel application with 500–800 PHP files typically completes in 20–40 seconds on a modern CI runner. Cache Composer dependencies and enable PHPStan result caching to keep merge request pipelines fast. Memory limit of 1G handles most apps.
Can PHPStan level 9 replace unit tests?
No. Static analysis proves type consistency and catches logic paths tests often miss. Unit and feature tests prove runtime behavior, database interactions, and external API contracts. Use both. PHPStan runs first because it is faster and catches entire bug classes tests never cover.
Does PHPStan work with PHP 8.5 and Laravel 13?
Yes. Larastan 3.x supports Laravel 12 and 13 on PHP 8.2 through 8.5. PHPStan 2.x understands readonly classes, enums, intersection types, and property hooks introduced in recent PHP versions. Pin versions in composer.lock and run analysis in CI on the same PHP version as production.
Build type-safe PHP that survives production
Adopting phpstan level 9 is a commitment to long-term code health, not a one-time cleanup sprint. Start with framework extensions, generate a baseline, and enforce strictness incrementally on new features. Use assert helpers, precise generics, and exhaustive match expressions to satisfy level 9 without drowning in boilerplate.
The discipline pays compounding dividends. Systems maintained at max strictness show fewer production incidents, faster onboarding, and safer refactors. For business-critical applications — from legal-tech portals to e-commerce platforms — type safety directly reduces maintenance cost and operational risk.
Need help integrating PHPStan into an existing Laravel or Symfony project? Contact us to discuss your codebase and adoption plan. You can also reach out directly for a quick technical assessment. Practical implementation beats theoretical perfection every time.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

