# Hardcore PHPStan: Maximum-strictness static analysis for Laravel

AI writes a lot of PHP now. It 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. For LLM consumption, a machine-readable version is available at [/phpstan.md](/phpstan.md).

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:

```bash
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](#disabled-rules-and-why) 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:

| 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 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:

| 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.

```yaml
# 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](https://github.com/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, 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:

```php
// 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:

```php
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:

```php
public function findByEmail(string $email): ?User
```

The alternative (throwing exceptions for "not found") has its place (I personally prefer [the Option type](https://github.com/schmittjoh/php-option)), but nullable returns are the established convention in the Laravel ecosystem.

### `ergebnis.noConstructorParameterWithDefaultValue`

Reasonable defaults in constructors reduce boilerplate:

```php
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:

```php
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](mailto:hello@pocketarc.com) or [X/Twitter](https://x.com/pocketarc).
