Hardcore PHPStan: Maximum-strictness static analysis for Laravel
AI writes a lot of PHP now. It writes it fast, and it writes it confidently. It also writes == where it should write ===, leaves dd() calls in place, skips return value checks, and defaults to mixed when it can't be bothered with types.
Static analysis catches all of these. This page documents the PHPStan configuration I use across my Laravel projects: level 10 with extra strictness packages on top, and the reasoning behind every choice. It's designed as a reference for humans setting up PHPStan and as a guardrail spec that LLMs can be pointed at when generating PHP.
The philosophy is maximum strictness with pragmatic exceptions for framework idioms. When AI is generating code at 10x speed, you need guardrails that match.
The packages
These are require-dev dependencies. Install them alongside PHPStan:
composer require --dev \
phpstan/phpstan \
phpstan/extension-installer \
phpstan/phpstan-strict-rules \
phpstan/phpstan-deprecation-rules \
larastan/larastan \
ergebnis/phpstan-rules \
shipmonk/phpstan-rules \
spaze/phpstan-disallowed-calls \
korbeil/phpstan-generic-rules \
thecodingmachine/phpstan-safe-rule \
thecodingmachine/safe \
tomasvotruba/cognitive-complexity
phpstan/phpstan
The core. PHPStan is a static analysis tool for PHP that finds bugs without running your code. It understands PHP's type system (generics, union and intersection types, conditional return types) and catches entire classes of bugs that tests miss.
phpstan/extension-installer
Automatically discovers and registers PHPStan extensions from your Composer dependencies. Without this, you'd need to manually add includes entries for each package. With it, packages like larastan, ergebnis/phpstan-rules, and shipmonk/phpstan-rules are registered automatically.
phpstan/phpstan-strict-rules
Adds dozens of rules that enforce defensive programming patterns. This is one of the highest-value packages for catching AI-generated code, because LLMs trained on PHP codebases absorb all of PHP's worst habits:
- Requires actual booleans in conditions. No more
if ($count)when you meanif ($count > 0). LLMs default to loose truthiness constantly because most PHP code on the internet does. - Disallows
==and!=. Loose comparison in PHP is unpredictable ("0" == false,"" == 0). LLMs reach for==by default and will keep doing it until something forces them to stop. - Disallows
empty(), which silently swallows undefined variables and treats0,"","0",[], andnullidentically. LLMs loveempty()as a catch-all null check. It's not. - Disallows variable variables (
$$var). Dynamic variable access makes code unpredictable and impossible to analyze statically. - Enforces strict mode on
in_array(),array_search(),array_keys(), andbase64_decode(), which must use theirstrictparameter to avoid loose comparison and silent decoding bugs. LLMs almost never pass the strict flag unprompted.
phpstan/phpstan-deprecation-rules
Detects usage of deprecated classes, methods, properties, constants, and traits marked with @deprecated PHPDoc tags or PHP attributes. LLMs are trained on code from across PHP's entire history, so they frequently reach for deprecated APIs. This rule catches those before they ship.
larastan/larastan
Laravel-specific static analysis. Laravel's heavy use of magic methods and facades makes vanilla PHPStan miss a lot. Larastan teaches PHPStan to understand:
- Eloquent model properties and relationships
- Facade proxies and their underlying classes
- Service container bindings
- Query builder return types
- Config and route helpers
Without Larastan, PHPStan would flag most Laravel code as errors, or worse, silently miss real bugs hidden behind magic methods.
ergebnis/phpstan-rules
Dozens of opinionated rules that enforce modern PHP architecture patterns:
- Requires
declare(strict_types=1). Without it, PHP silently coerces types in function calls, which defeats the purpose of type declarations. LLMs frequently omit the strict types declaration unless your existing files already have it. - Forbids
eval(). Arbitrary code execution has no place in application code. - Forbids error suppression (
@). The@operator hides errors instead of fixing them. - Forbids
switchin favor ofmatch.matchuses strict comparison and is an expression (returns a value), making it safer and more concise. LLMs tend to generateswitchstatements because they appear far more often in training data. - Forbids
isset()in favor of explicit null checks.isset()silently handles undefined variables, masking bugs. - Forbids assigning by reference. Reference assignment creates hidden coupling between variables and makes code harder to reason about.
Some of ergebnis's rules are too strict for Laravel projects (like forbidding extends), so those are selectively disabled. See Disabled rules below.
shipmonk/phpstan-rules
Dozens of rules from production use at ShipMonk. These catch real-world bugs and edge cases:
- Forbids unsafe array keys. Using
float,null, orboolas array keys triggers silent type coercion. Afloatkey gets truncated toint, anullkey becomes"". - Forbids null in binary operations and string interpolation. Catches
"Hello $name"when$namemight benull. - Enforces readonly public properties. If a property is public and mutable, any code anywhere can change it without validation or tracking. If the value needs to change, a setter method gives you a place to enforce invariants. Public readonly gives you the convenience of direct access with the safety of immutability.
- Enforces
list<T>overarray<T>for sequential arrays, making the intent clear and preventing gaps in numeric keys. - Forbids default arms in enum match expressions, forcing exhaustive handling of every enum case. When you add a new case, the analyzer tells you every place that needs updating. LLMs like to add
defaultarms to match expressions as a safety net, which silently swallows new enum cases. - Tracks checked exceptions in closures, catching cases where exceptions thrown inside closures might not propagate correctly.
spaze/phpstan-disallowed-calls
A flexible framework for banning specific function calls, method calls, constants, and superglobal access. It ships with curated presets:
| Preset | What it bans |
|---|---|
disallowed-dangerous-calls.neon | Functions dangerous in production: var_dump, print_r, phpinfo, extract, eval, putenv, create_function, and others |
disallowed-execution-calls.neon | Code execution: exec, shell_exec, system, passthru, proc_open, popen, pcntl_exec, backtick operator |
disallowed-insecure-calls.neon | Insecure functions: md5, sha1, rand, mt_rand, uniqid, insecure mysql functions |
disallowed-loose-calls.neon | in_array() without strict mode, htmlspecialchars() without ENT_QUOTES |
On top of these presets, the config adds project-specific bans (see the config below). The insecure calls preset is particularly worth noting: LLMs will happily use md5() for hashing and rand() for random number generation if nothing stops them.
korbeil/phpstan-generic-rules
Enforces multibyte string function usage. Functions like strlen(), strpos(), substr(), strtolower(), strtoupper(), stripos(), strstr(), strrchr(), and substr_count() break on multibyte UTF-8 strings. For example, strlen('café') returns 5 (bytes), not 4 (characters). This package flags all 12 affected functions and suggests their mb_ equivalents. LLMs almost always generate the non-multibyte variants.
thecodingmachine/phpstan-safe-rule
Works with thecodingmachine/safe, which wraps over a thousand core PHP functions that normally return false on error. The wrappers throw exceptions instead.
This rule detects when you call the unsafe originals and tells you to use the safe wrappers:
json_decode()->Safe\json_decode()(throws on invalid JSON instead of returningnull)file_get_contents()->Safe\file_get_contents()(throws on failure instead of returningfalse)preg_match()->Safe\preg_match()(throws on invalid regex instead of returningfalse)
PHP's pattern of returning false on failure is a backwards compatibility artifact that we're stuck with in core functions, but we don't have to live with it. Every unchecked return value is a potential silent failure, and this package eliminates that entire class of bugs. LLMs are especially bad at this: they call file_get_contents() or json_decode() and proceed without checking the return value, because the "happy path" code is what appears most in training data.
tomasvotruba/cognitive-complexity
Measures and enforces limits on code complexity using the cognitive complexity metric. Unlike cyclomatic complexity, cognitive complexity weights nested structures and break-in-flow more heavily, better reflecting how hard code is to understand.
This is probably the single most impactful rule for AI-generated code. Without complexity limits, LLMs will happily keep piling logic on and on in functions. Set a complexity limit of 10, and the LLM is forced to break the function into smaller units with clear responsibilities instead.
The thresholds in this config:
| Scope | Limit | Rationale |
|---|---|---|
| Function | 10 | Any single function more complex than this needs to be broken up. |
| Class | 50 | Any class exceeding this is likely a god class that should be decomposed. |
The full config
This is the complete phpstan.neon. Each section is explained below.
# bleedingEdge.neon is intentional; CI may break when new PHPStan releases arrive.
includes:
- phar://phpstan.phar/conf/bleedingEdge.neon
- vendor/spaze/phpstan-disallowed-calls/disallowed-dangerous-calls.neon
- vendor/spaze/phpstan-disallowed-calls/disallowed-execution-calls.neon
- vendor/spaze/phpstan-disallowed-calls/disallowed-insecure-calls.neon
- vendor/spaze/phpstan-disallowed-calls/disallowed-loose-calls.neon
parameters:
level: 10
paths:
- src
scanFiles:
- _ide_helper.php
ergebnis:
noNamedArgument:
enabled: false
noExtends:
enabled: false
noParameterWithNullableTypeDeclaration:
enabled: false
noParameterWithNullDefaultValue:
enabled: false
noNullableReturnTypeDeclaration:
enabled: false
noConstructorParameterWithDefaultValue:
enabled: false
final:
enabled: false
finalInAbstractClass:
enabled: false
# Larastan checks
checkModelProperties: true
checkOctaneCompatibility: true
noEnvCallsOutsideOfConfig: true
checkModelAppends: true
# PHPStan strictness
checkTooWideReturnTypesInProtectedAndPublicMethods: true
checkUninitializedProperties: true
checkImplicitMixed: true
checkBenevolentUnionTypes: true
reportPossiblyNonexistentGeneralArrayOffset: true
reportPossiblyNonexistentConstantArrayOffset: true
reportAlwaysTrueInLastCondition: true
reportAnyTypeWideningInVarTag: true
checkMissingOverrideMethodAttribute: true
checkMissingCallableSignature: true
# Cognitive complexity thresholds
cognitive_complexity:
function: 10
class: 50
# Functions that should never appear in production code.
disallowedFunctionCalls:
-
function: 'dd()'
message: 'use a logger or proper exception handling'
-
function: 'dump()'
message: 'use a logger or proper exception handling'
-
function: 'ray()'
message: 'remove debug call'
-
function: 'die()'
message: 'use a proper exception or return statement'
-
function: 'exit()'
message: 'use a proper exception or return statement'
-
function: 'compact()'
message: 'use explicit array construction instead'
-
function: 'assert()'
message: 'use a proper exception or runtime check'
-
function: 'error_log()'
message: 'use the Log facade instead'
-
function: 'trigger_error()'
message: 'throw an exception instead'
-
function: 'settype()'
message: 'use explicit type casting instead'
-
function: 'define()'
message: 'use class constants instead'
-
function: 'print()'
message: 'use the Log facade or a Response instead'
-
function: 'call_user_func()'
message: 'invoke the callable directly instead'
-
function: 'call_user_func_array()'
message: 'invoke the callable directly with the spread operator instead'
ignoreErrors:
-
identifier: property.missingOverride
reportUnmatched: false
- identifier: staticMethod.dynamicCall
Level 10 + bleeding edge
Level 10 is PHPStan's maximum strictness level. Each level adds checks on top of the previous one. At level 10, PHPStan checks everything it knows how to check, including how mixed types are used and whether method calls are valid on all branches of a union type.
Bleeding edge (bleedingEdge.neon) opts into rules that PHPStan is developing but hasn't promoted to stable yet. The trade-off is that your CI might break when a new PHPStan version introduces a stricter check. That's intentional: you want to find out about new checks as early as possible, and you can add targeted ignoreErrors for any false positives.
scanFiles and _ide_helper.php
The config references _ide_helper.php, generated by barryvdh/laravel-ide-helper. This file provides PHPStan with type information for facades, model properties, and other Laravel magic. Any project aiming for level 10 with Larastan will almost certainly need it. If you're not using laravel-ide-helper, remove this line.
Larastan parameters
| Flag | What it catches |
|---|---|
checkModelProperties | Property access on Eloquent models that doesn't correspond to a database column, cast, or accessor. Catches typos like $user->emal instead of $user->email. |
checkOctaneCompatibility | Patterns that break in long-running processes (Laravel Octane), like using app() or request() helpers in constructors, which capture stale state across requests. |
noEnvCallsOutsideOfConfig | env() calls outside of config/ files. When you run php artisan config:cache, env() returns null everywhere except in config files. |
checkModelAppends | Attributes listed in a model's $appends array that don't have corresponding accessor methods. |
LLMs frequently generate env() calls inline in service providers, middleware, or even controllers. The noEnvCallsOutsideOfConfig rule catches every one of them.
Strictness flags
| Flag | What it catches |
|---|---|
checkTooWideReturnTypesInProtectedAndPublicMethods | A method declares string|int but only ever returns string. Forces precise return types. |
checkUninitializedProperties | Properties declared but never assigned before use. A common source of null access errors. |
checkImplicitMixed | Operations on mixed values without narrowing them first. Prevents silent type coercion. |
checkBenevolentUnionTypes | Union types that PHPStan normally treats leniently (e.g., array_keys() returning list<int|string> treated as list<int>). |
reportPossiblyNonexistentGeneralArrayOffset | $array['key'] when the key might not exist. Forces ?? $default or array_key_exists(). |
reportPossiblyNonexistentConstantArrayOffset | Same as above, for constant array shapes. |
reportAlwaysTrueInLastCondition | elseif/else branches where the condition is always true. Usually dead code or a logic error. |
reportAnyTypeWideningInVarTag | @var annotations that widen a type beyond what PHPStan inferred. Prevents @var from weakening type safety. |
checkMissingOverrideMethodAttribute | Methods that override a parent but lack #[\Override]. If the parent method is renamed, PHP throws an error instead of silently creating an unrelated method. |
checkMissingCallableSignature | Bare callable types instead of typed signatures like Closure(int): string. Makes higher-order function interfaces explicit. |
checkImplicitMixed and checkMissingCallableSignature are great flags for AI-generated code. LLMs default to mixed and bare callable because they're the path of least resistance. These flags force explicit types everywhere.
Disallowed function calls
These are project-specific bans layered on top of the spaze/phpstan-disallowed-calls presets:
dd()/dump()/ray(): debug functions that should never reach production. Use structured logging (Log::info(),Log::error()) or throw a proper exception. LLMs leave these in generated code constantly, especiallydd()in Laravel.die()/exit(): hard process termination that bypasses the entire middleware and error handling stack. Use exceptions for error cases and return statements for control flow.compact(): creates an array from variable names as strings (compact('name', 'email')). Fragile because renaming a variable breaks it silently (the key just disappears from the array). Use explicit array construction (['name' => $name, 'email' => $email]), which is refactor-safe and self-documenting.assert(): can be disabled at runtime viazend.assertions, meaning your safety check might silently do nothing in production. LLMs are especially prone to reaching forassert()to shut PHPStan up, and if the assertion turns out to be wrong, it fails silently instead of loudly. Use a proper exception or explicitifcheck instead.error_log(): bypasses Laravel's entire logging infrastructure (levels, channels, context, formatting). Use theLogfacade. LLMs reach forerror_log()because it's simpler.trigger_error(): generates PHP errors (E_USER_ERROR, E_USER_WARNING) which can be silenced with@, don't carry stack traces, and behave differently depending onerror_reportingsettings. Exceptions are the modern mechanism: they carry context and stack traces, and they can't be silently suppressed.settype(): mutates a variable's type in place, so code after the call sees a different type than code before. Static analysis can't track this reliably, and it fails silently on bad input (returnsfalse). Use explicit casting ((int),(string), etc.) which produces a new value you can assign to a typed variable.define(): creates global constants that pollute the global namespace. Use class constants instead. LLMs default todefine()because it appears more in older PHP code.print(): raw output bypasses Laravel's response lifecycle (middleware, headers, content negotiation, testing). Output should go through Response objects in web contexts or theLogfacade for logging.call_user_func()/call_user_func_array(): unnecessary in modern PHP. Invoke callables directly ($callable()) or use the spread operator ($callable(...$args)). LLMs generate these frequently because they appear in older codebases.
Disabled rules (and why)
Not all rules fit all projects. These are intentionally disabled, and strictness is the goal, but dogma isn't.
ergebnis.noNamedArgument
Named arguments improve readability for methods with many parameters. Compare:
// Without named arguments
$integration->logOperation('sync', true, false, 30);
// With named arguments
$integration->logOperation('sync', successful: true, cached: false, duration: 30);
The rule exists because named arguments create a coupling to parameter names (not just position), which can break if parameter names change. That's a valid concern for public libraries consumed by third parties, but within your own codebase, the readability benefit outweighs the risk.
ergebnis.noExtends
This rule enforces composition over inheritance, which is generally good advice. But Laravel requires extending framework base classes (Model, Command, ServiceProvider, Controller, FormRequest, Notification, Job, Mailable, etc.). Enabling this rule in a Laravel project would flag virtually every class.
ergebnis.noParameterWithNullableTypeDeclaration / noParameterWithNullDefaultValue
Nullable parameters are standard PHP for optional arguments:
public function find(int $id, ?string $locale = null): Model
The ergebnis philosophy prefers separate methods or builder patterns to avoid nullability. That's reasonable for domain modeling, but in framework-integrated code where you're implementing interfaces or following established conventions, nullable parameters are idiomatic and expected.
ergebnis.noNullableReturnTypeDeclaration
Nullable returns are idiomatic PHP/Laravel for "not found" and optional semantics:
public function findByEmail(string $email): ?User
The alternative (throwing exceptions for "not found") has its place (I personally prefer the Option type), but nullable returns are the established convention in the Laravel ecosystem.
ergebnis.noConstructorParameterWithDefaultValue
Reasonable defaults in constructors reduce boilerplate:
public function __construct(
private readonly int $timeout = 30,
private readonly int $retries = 3,
private readonly ?\Throwable $previous = null,
)
Forcing every caller to explicitly pass every value when sensible defaults exist adds noise without adding safety.
ergebnis.final / ergebnis.finalInAbstractClass
The final keyword prevents extending a class. For library packages, disabling this rule makes sense because consumers should be free to extend classes in ways you didn't anticipate. For application code, enforcing final has zero value because you control all the code anyway. If you want to extend something, you will. Either way, this rule is overkill.
Ignored errors (with context)
The goal is zero ignores, but some are necessary when framework patterns conflict with strict analysis.
property.missingOverride
The #[\Override] attribute on properties is a PHPStan bleeding edge feature, but PHP 8.3 doesn't support it at runtime. Adding it would cause a fatal error. If your project is on PHP 8.4+, this ignore can be removed.
staticMethod.dynamicCall
Eloquent's fluent query builder chains static and dynamic calls interchangeably:
User::where('active', true)->first();
PHPStan correctly identifies that where() is a static method being called dynamically, but this is an intentional Laravel design pattern. Flagging it would generate noise on every query builder chain.
Adding your own ignores
Your project will likely need its own ignoreErrors entries for framework patterns that trigger false positives. When adding them, be specific: use identifier, path, and count so that any new occurrence of the same error is still caught. Two common ones in Laravel projects:
ergebnis.noParameterWithContainerTypeDeclaration: if you have a class that accepts the service container for lazy resolution (e.g., a plugin manager that calls$container->make()), you'll need to ignore this for that specific class.shipmonk.checkedExceptionInCallable: Laravel closures passed toRoute::group(),DB::transaction(), and HTTP middleware are immediately invoked, so exceptions propagate fine. ShipMonk flags them anyway because it can't know the closure is invoked immediately. Ignore these per-file with an exact count.
This page is a living reference and will be updated as the configuration evolves. If you spot an error or think something is missing, reach out via email or X/Twitter.