PHPStan Level 10 in Laravel — Fix the 5 Most Common Errors

5 min read

You bump PHPStan to level 10 on your Laravel app, run the analyser, and suddenly you have three hundred errors. Most of them are the same five patterns repeated across every model, controller, and service class. Here's exactly what they are and how to clear them.

Setting Up PHPStan Level 10 in Laravel

Larastan is the PHPStan extension that understands Laravel's conventions — facades, Eloquent magic, service container bindings. Install it as a dev dependency:

composer require --dev "larastan/larastan:^3.0"

Create phpstan.neon in your project root:

includes:
    - vendor/larastan/larastan/extension.neon

parameters:
    paths:
        - app
    level: 10

Run the analysis:

vendor/bin/phpstan analyse

Level 10 was introduced in PHPStan 2.0. It goes beyond level 9 (which flags explicit mixed types) to catch implicit mixed — any place where type information is missing entirely. That's why it's noisy on an existing codebase. You're not necessarily writing bad code; you're missing annotations that PHPStan needs to trace types through Laravel's dynamic layer.

Larastan is one of those tools that sits in the complete Laravel developer toolchain for 2026 alongside Pint and Pest — it pays back its setup cost the first time it catches a bug your tests didn't cover.

Error 1: Model Magic Property Access

Access to an undefined property App\Models\User::$name.

Eloquent models use __get() magic for attribute access. PHPStan can't trace through that at level 10. Fix it by adding a @property block to the model's class-level PHPDoc:

/**
 * @property int $id
 * @property string $name
 * @property string $email
 * @property \Carbon\Carbon|null $email_verified_at
 * @property \Carbon\Carbon $created_at
 * @property \Carbon\Carbon $updated_at
 */
class User extends Authenticatable
{
    // ...
}

Doing this by hand for every model is tedious. The barryvdh/laravel-ide-helper package generates these automatically:

composer require --dev barryvdh/laravel-ide-helper
php artisan ide-helper:models --write-mixin

The --write-mixin flag adds a @mixin annotation to each model pointing at a generated helper class, which Larastan picks up automatically. I add php artisan ide-helper:models --write-mixin to my Makefile so it's one command after any migration or model change.

If you're using PHP 8.4 property hooks in Laravel models, those are typed directly on the property declaration — no @property PHPDoc needed for them.

Error 2: Facade Return Types

Cannot call method profile() on mixed.

Facades delegate to underlying implementations, and at level 10, PHPStan needs to know the concrete type. The most common offender is Auth::user(), which returns Authenticatable|null — not your App\Models\User. If you call model-specific methods on it, PHPStan has no idea what you're working with:

// ❌ PHPStan doesn't know $user is App\Models\User
$user = Auth::user();
$user->profile; // Error: property not found on Authenticatable

// ✅ Assert the concrete type inline
$user = Auth::user();
assert($user instanceof \App\Models\User);
$user->profile; // PHPStan now knows the type

// ✅ Or use a typed helper method for repeated access
private function currentUser(): \App\Models\User
{
    $user = Auth::user();
    assert($user instanceof \App\Models\User);
    return $user;
}

The assert() approach doubles as documentation — it makes the assumption explicit in the code rather than hiding it in a comment. For authenticated-only controllers, placing the assertion in the constructor and assigning to a typed property keeps things clean.

Error 3: Collection Generic Types

Method App\Models\User::posts() return type with generic class
Illuminate\Database\Eloquent\Relations\HasMany does not specify its types.

Eloquent relationships are generic classes. At level 10, PHPStan requires you to declare what's inside them:

// ❌ Missing generic type parameters
public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}

// ✅ Fully typed
/** @return HasMany<Post, $this> */
public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}

The pattern is HasMany<RelatedModel, $this>. The same applies across all relation types:

/** @return BelongsTo<Team, $this> */
public function team(): BelongsTo
{
    return $this->belongsTo(Team::class);
}

/** @return BelongsToMany<Role, $this> */
public function roles(): BelongsToMany
{
    return $this->belongsToMany(Role::class);
}

/** @return HasOne<Profile, $this> */
public function profile(): HasOne
{
    return $this->hasOne(Profile::class);
}

If you have dozens of relations to annotate across a large model, use the baseline approach at the end of this article to tackle them incrementally.

Error 4: Untyped Route Closure Parameters

Parameter $id of anonymous function has no type specified.

Route closures without type hints produce implicit mixed at level 10:

// ❌ PHPStan flags $id as implicit mixed
Route::get('/users/{id}', function ($id) {
    return User::findOrFail($id);
});

// ✅ Use route model binding — typed and removes the findOrFail call
Route::get('/users/{user}', function (User $user) {
    return $user;
});

// ✅ Or move to a dedicated controller (preferred for anything beyond trivial)
Route::get('/users/{user}', [UserController::class, 'show']);

Route model binding is the cleanest fix — you get a fully typed variable and remove the manual lookup. If you want to enforce this pattern across the team, Pest architecture testing rules for Laravel apps can codify it as a failing test rather than relying on PHPStan alone.

Error 5: Config Helper Return Type Narrowing

Cannot cast mixed to string.

The config() helper returns mixed because config values are loaded from arrays at runtime. At level 10, you can't use mixed in typed parameter positions without narrowing it first:

// ❌ config() returns mixed — can't pass to functions expecting string
$appName = config('app.name');
$subject = "Welcome to {$appName}"; // Error

// ✅ Provide a typed default — narrows to mixed|string, still needs a cast
$appName = (string) config('app.name', '');
$subject = "Welcome to {$appName}"; // OK

// ✅ For non-string types, use explicit assertion
$timeout = config('services.stripe.timeout', 30);
assert(is_int($timeout));

The (string) cast with an empty string default is the pattern I reach for most often — it's concise and makes the expected type obvious. Use assert(is_int(...)) or assert(is_array(...)) for config values that aren't strings.

Baseline for PHPStan Level 10 Legacy Projects

Running Larastan at level 10 on an existing codebase might surface hundreds of errors you can't clear in one sitting. The baseline lets you acknowledge existing errors and only fail on new ones:

vendor/bin/phpstan analyse --generate-baseline

This creates phpstan-baseline.neon. Include it in your config:

includes:
    - vendor/larastan/larastan/extension.neon
    - phpstan-baseline.neon

parameters:
    paths:
        - app
    level: 10

Commit the baseline. CI passes. Then chip away at the reported errors file by file — each time you clear a class, regenerate the baseline with --generate-baseline. The count will only go down, never up on untouched files.

This isn't a permanent solution, but it's a clean migration path. Bumping level gradually (5 → 7 → 9 → 10) works too, but with the baseline you can jump straight to 10 and stay there.

Wrapping Up

Level 10 is noisy on first run but the errors cluster into the same handful of patterns. Model @property annotations, typed relation generics, facade type assertions, route closure types, and config casting cover the vast majority of what you'll encounter in a typical Laravel app.

For teams enforcing code quality across a CI pipeline, PHPStan pairs well with running Laravel Pint automatically on every commit with pre-commit hooks — static analysis catches type errors, Pint catches style drift, and neither requires any manual review overhead.

laravel
phpstan
tooling
static-analysis
php
Steven Richardson

Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.