I Ran Larastan at Every Level on Agent-Written Laravel Code: 4 Real Bugs, 3 Kinds of Noise
Larastan from level 0 to 10 on a production Laravel app written mostly by an agent. Where the real bugs were, and what the noise was really about.
On October 12, Nuno Maduro posted that "it's kind of insane how much AI agents love nullables." His maths was simple. Every nullable doubles the number of states your code can be in, so a constructor with ten of them has over a thousand combinations to reason about.
I wanted to see what that looks like in a real codebase, so I ran Larastan against one of mine at every level from 0 to 10. It's a production Laravel SaaS on Laravel 12 and Filament 4, about 25,000 lines across roughly 250 files in app/, built over the last fifteen months. Most of that code was written by Claude Code, with me reviewing it.
The four real bugs all showed up at level 0. Almost everything above that was typing noise with three causes, and two of them took one line each to fix. The nullables were there too. Some ?-> operators guarded values that could never be null, and some nullable values had no guard at all.
The setup, and the run that died first
Larastan 3.13.0 came out on October 7 and pulls in PHPStan 2.3.1. I cloned the repo into a scratch directory, so nothing here touched the real project:
composer require --dev larastan/larastan
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
parameters:
paths:
- app/
level: 0
The first run didn't finish:
Internal error: $config must be a string or an array while analysing file
app/Console/Commands/SendActivateDormantCampaign.php
Larastan boots your application, and to type a command's arguments it resolves every Artisan command you have. The file in the error was just the one being analysed. The culprit was another command whose constructor asked the container for a Stripe service, and the service built its Stripe client in its own constructor from config('services.stripe.secret'). With no .env, the secret was null and the Stripe SDK threw. Copying .env.example didn't help either, because an empty STRIPE_SECRET= gives an empty string, and the run died again with "api_key cannot be the empty string".
So static analysis needs a booting app, and in CI that means an .env with non-empty dummy values for anything a constructor reads. The cleaner fix is in the code. A service that talks to an external API shouldn't build its client until something calls it.
Level 0 found four real bugs
Level 0 is meant to be the gentle one. It found the only findings in the whole run that would break in production.
A Filament v3 class in a Filament v4 app. One admin View page called Section::make() eight times, imported from Filament\Infolists\Components\Section. In Filament 4 that class lives in Filament\Schemas\Components, and the old one doesn't exist (I checked vendor/ on 4.5.2). The page throws the moment anyone opens it. Larastan said so in one line:
Call to static method make() on an unknown class Filament\Infolists\Components\Section.
It's the import an agent writes when it remembers Filament v3. The rest of the file was v4 code.
env() outside the config directory. Two places read an admin email like this:
$adminEmail = config('mail.admin_email', env('ADMIN_EMAIL', '[email protected]'));
There's no mail.admin_email key, so it always falls through to env(). And once the config is cached, "the .env file will not be loaded", so env() only sees real system variables. In production the ADMIN_EMAIL value was ignored and the hardcoded fallback won. Larastan's message names the exact problem: "Called 'env' outside of the config directory which returns null when the config is cached, use 'config'."
Enum cases that no longer exist. Two model methods compared a property against enum cases that had been removed from the enum at some point. Nothing calls either method today, which is the only reason nothing broke. The first caller would get an Error, because PHP throws on an undefined enum case.
A duplicate array key. An import command had a lookup map with the same key twice, so the second entry silently replaced the first. Nothing crashes. One mapping just quietly never happens.
None of these four were exotic. All four would have been caught by vendor/bin/phpstan at its lowest setting, on any day in the last year.
Three kinds of noise, and the one-line fixes
From level 2 upwards the counts climbed fast, and almost none of it was a bug. Three causes covered most of it.
Relations without generics
At level 2, almost half the findings mentioned a plain Illuminate\Database\Eloquent\Model. Things like HasMany::valid() being an "undefined method", when valid is a perfectly good scope on the related model. The app has 34 relation methods. Not one of them had a generic return type, so Larastan couldn't tell which model a relation returned.
One docblock per relation fixes it:
/** @return HasMany<ShareToken, $this> */
public function shareTokens(): HasMany
{
return $this->hasMany(ShareToken::class);
}
That single line removed five level-2 errors in the files that used the relation. It's tedious to add 34 times, which is exactly the kind of work to hand back to the agent that skipped it.
Casts that Larastan didn't read
Larastan kept saying a strict comparison against an enum case would "always evaluate to false", on a model whose casts() method clearly casts that column to the enum. A quick \PHPStan\dumpType() showed Larastan thought the property was a plain string.
The Larastan docs explain why. By default it relies on the return type of the casts() method, and a method declared as returning array says nothing about which cast goes where. Parsing the method body is opt-in, because it's slower:
parameters:
parseModelCastsMethod: true
Turning it on cut the level 4 findings by 37% and the level 2 findings by about a quarter. If your models use casts() rather than the old $casts property, set this before you read a single result.
mixed at levels 9 and 10
Every new finding at level 9 was about mixed, and 93% at level 10. A common source was config(), which returns mixed, so config('app.url').'/about' becomes Binary operation "." between mixed and '/about' results in an error. Laravel already has typed getters for this:
use Illuminate\Support\Facades\Config;
'url' => Config::string('app.url').'/about',
That cleared the finding on that line. It also throws if the value isn't a string, which is better than concatenating null into a URL.
The nullable part
So, did the agent love nullables? Yes, but in two opposite ways.
At level 4, Larastan flagged eight nullsafe operators the code didn't need. Two were on values that can never be null:
Using nullsafe property access on non-nullable type App\Models\User. Use -> instead.
The other six sat on the left of ??, which already handles null. Each ?-> tells the next reader to worry about a null that is either impossible or already handled. That's Nuno's point exactly.
Level 8 is where PHPStan starts reporting method calls on nullable types, and there the agent did the reverse. It called methods on values that really are nullable by type, without narrowing them. Most came from Laravel APIs where the app knows more than the type does:
$request->user()inside routes behind auth middleware, which returnsUser|nullby type. Code using that user accounted for 33 level-8 findings.->first()straight after anexists:validation rule, so the row is there but the type says it might not be.- Filament's
$this->record, typedModel|int|string|nullon page classes.
Same root cause both times. The agent handled null from habit instead of reading the types. And the fixes share one idea. Make the type say what the code already knows. For the controllers, Laravel's #[CurrentUser] attribute injects a non-null user:
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
public function me(#[CurrentUser] User $user): JsonResponse
{
$activeSub = $user->activeSubscription();
// ...
}
That change cleared all nine level-8 findings in that controller. For the lookups, firstOrFail() instead of first() does the same job and turns a would-be null error into a 404.
PHPStan now talks to your agent
When I ran PHPStan from inside a Claude Code session, the output opened with a block of instructions:
Before fixing the error, fetch the documentation page for its identifier.
The error usually indicates a real bug or incorrect type in the code. Fix the underlying cause, do not just make the error go away.
Do not add `@phpstan-ignore` comments, `@phpstan-ignore-next-line` comments, or baseline entries to suppress the error.
Do not use assert() or inline @var PHPDoc tag to override PHPStan's inferred type.
Do not add type casts just to silence errors.
Do not widen parameter or return types just to make the error go away.
PHPStan added this in 2.1.41 in March, and later versions detect more agents. It checks for environment variables like CLAUDECODE, AMP_CURRENT_THREAD_ID and CURSOR_AGENT, and only prints the block when one is set and there are errors. You won't see it in a normal terminal session.
The quickest way for an agent to make a nullable error disappear is to add a ?->, an @var or a cast. PHPStan now rules out the @var and the cast by name, in the same output that lists the errors.
A baseline and a cache for CI
Fixing everything at level 8 in one go isn't realistic, and you don't need to. Generate a baseline, commit it, and from then on only new code has to be clean:
vendor/bin/phpstan analyse --generate-baseline
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
paths:
- app/
level: 8
parseModelCastsMethod: true
tmpDir: .phpstan-cache
PHPStan also won't let the baseline go stale. When I fixed one of the baselined findings by switching a first() to firstOrFail(), the next run failed with this:
Ignored error pattern #^Cannot access property \$id on App\\Models\\AffiliateProgram\|null\.$#
(property.nonObject) in path app/Http/Controllers/AffiliateClickController.php
was not matched in reported errors. [identifier=ignore.unmatched]
That error can't be ignored. It's PHPStan asking you to regenerate the baseline, so fixed problems can't quietly come back.
Then the cache. On an M1 Pro, a cold run at level 6 took 3 to 4 seconds. Limited to two processes, like a small CI runner, a cold run at level 10 took 4.2 to 4.7 seconds. With a warm result cache, the same run took 1.1 seconds. After I edited one central service class, it took 2.1 to 2.6 seconds, because PHPStan re-analyses the changed file and everything that references it. It's the same idea as running only the tests your change touched.
Honestly, at 25,000 lines a cold run is cheap. The cache starts to matter on bigger apps. If you want it in GitHub Actions anyway, the PHPStan docs give the recipe. Put tmpDir inside the workspace (the config above does), then restore and save it with a unique key per run:
- name: "Restore result cache"
uses: actions/cache/restore@v5
with:
path: .phpstan-cache
key: "phpstan-result-cache-${{ github.run_id }}"
restore-keys: |
phpstan-result-cache-
- name: "Run PHPStan"
run: "vendor/bin/phpstan"
- name: "Save result cache"
uses: actions/cache/save@v5
if: ${{ !cancelled() }}
with:
path: .phpstan-cache
key: "phpstan-result-cache-${{ github.run_id }}"
That's the docs' version with the path changed to match. I haven't run this workflow, so treat it as the documented starting point. The .env with dummy secrets from the setup section goes in the step before it. For the rest of the workflow, see my GitHub Actions pipeline for Laravel.
Which level to run
Run level 0 today, with no baseline. It's fast, it found every real bug in this app, and this agent-written codebase had stale framework APIs in it.
Then add parseModelCastsMethod, generics on your relations and typed config getters. Those three remove most of what makes the higher levels unreadable.
After that, level 8 with a baseline. That's the level where nullable handling gets checked, which is the habit this codebase most needed correcting. Levels 9 and 10 were all about mixed, mostly from config and decoded JSON. Worth it later. Not first.
FAQ
What Larastan level should I start with on an existing Laravel app?
Start at level 0 without a baseline and fix what it finds, because those are the findings most likely to be real bugs. Then pick the highest level you want new code to meet, often 5 or 8, and generate a baseline for everything that already fails it.
Why does Larastan ignore my Eloquent casts?
If your model uses the casts() method, Larastan only reads its declared return type by default, and array tells it nothing. Set parseModelCastsMethod: true in phpstan.neon so it parses the method body. Models using the $casts property don't need this.
Does Larastan need a database or an .env file?
It doesn't need a database connection for this, but it does boot your application. Any service provider or command constructor that reads a secret and fails on null or an empty string will crash the analysis. Give CI an .env with non-empty dummy values, or build API clients lazily.
Why does PHPStan print instructions for AI agents?
Since PHPStan 2.1.41, when it detects a coding agent through environment variables such as CLAUDECODE or CURSOR_AGENT, it prints guidance telling the agent to fix the cause instead of suppressing errors. It only appears when there are errors and an agent variable is set.
What PHPStan won't catch
PHPStan only checks types. Keep the guardrails that stop an agent doing damage in place too, because a clean level 8 won't stop a migration from dropping a table.
About Hafiz
Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.
Get in touch →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Going Cloud-Only With Laravel: What 7 Weeks on a €5 Dev Box Taught Me
Taylor Otwell wants to skip local dev tools. I moved my Laravel work onto a €5 b...
Squashing Laravel Migrations: 5 Ways schema:dump --prune Bites a Real Team
schema:dump --prune turns hundreds of migrations into one file. I reproduced the...
Pest 5 TIA: Run Only the Tests Your Code Change Touched
Pest 5 shipped Test Impact Analysis. It reruns only the tests your change actual...