PocketArC LogoPocketArC

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 mean if ($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 treats 0, "", "0", [], and null identically. LLMs love empty() 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(), and base64_decode(), which must use their strict parameter 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 switch in favor of match. match uses strict comparison and is an expression (returns a value), making it safer and more concise. LLMs tend to generate switch statements 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, or bool as array keys triggers silent type coercion. A float key gets truncated to int, a null key becomes "".
  • Forbids null in binary operations and string interpolation. Catches "Hello $name" when $name might be null.
  • 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> over array<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 default arms 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:

PresetWhat it bans
disallowed-dangerous-calls.neonFunctions dangerous in production: var_dump, print_r, phpinfo, extract, eval, putenv, create_function, and others
disallowed-execution-calls.neonCode execution: exec, shell_exec, system, passthru, proc_open, popen, pcntl_exec, backtick operator
disallowed-insecure-calls.neonInsecure functions: md5, sha1, rand, mt_rand, uniqid, insecure mysql functions
disallowed-loose-calls.neonin_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 returning null)
  • file_get_contents() -> Safe\file_get_contents() (throws on failure instead of returning false)
  • preg_match() -> Safe\preg_match() (throws on invalid regex instead of returning false)

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:

ScopeLimitRationale
Function10Any single function more complex than this needs to be broken up.
Class50Any 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

FlagWhat it catches
checkModelPropertiesProperty access on Eloquent models that doesn't correspond to a database column, cast, or accessor. Catches typos like $user->emal instead of $user->email.
checkOctaneCompatibilityPatterns that break in long-running processes (Laravel Octane), like using app() or request() helpers in constructors, which capture stale state across requests.
noEnvCallsOutsideOfConfigenv() calls outside of config/ files. When you run php artisan config:cache, env() returns null everywhere except in config files.
checkModelAppendsAttributes 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

FlagWhat it catches
checkTooWideReturnTypesInProtectedAndPublicMethodsA method declares string|int but only ever returns string. Forces precise return types.
checkUninitializedPropertiesProperties declared but never assigned before use. A common source of null access errors.
checkImplicitMixedOperations on mixed values without narrowing them first. Prevents silent type coercion.
checkBenevolentUnionTypesUnion 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().
reportPossiblyNonexistentConstantArrayOffsetSame as above, for constant array shapes.
reportAlwaysTrueInLastConditionelseif/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.
checkMissingOverrideMethodAttributeMethods that override a parent but lack #[\Override]. If the parent method is renamed, PHP throws an error instead of silently creating an unrelated method.
checkMissingCallableSignatureBare 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, especially dd() 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 via zend.assertions, meaning your safety check might silently do nothing in production. LLMs are especially prone to reaching for assert() to shut PHPStan up, and if the assertion turns out to be wrong, it fails silently instead of loudly. Use a proper exception or explicit if check instead.
  • error_log(): bypasses Laravel's entire logging infrastructure (levels, channels, context, formatting). Use the Log facade. LLMs reach for error_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 on error_reporting settings. 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 (returns false). 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 to define() 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 the Log facade 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 to Route::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.