nunomaduro/larastan
Larastan is a PHPStan extension for Laravel that adds static typing and catches bugs before you write tests. It understands Laravel's magic and boots your app container to resolve runtime types, supporting Laravel 9 through 11+.
Larastan is a PHPStan extension that boots your Laravel container, so it can type-check things plain PHPStan can't see: Eloquent attributes, relations, collections, facades, config, translations, Blade views.
Day-one setup (3 commands, 1 file):
composer require --dev "larastan/larastan:^3.0" # PHP 8.2+, Laravel 11.15+
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
- vendor/nesbot/carbon/extension.neon
parameters:
paths:
- app/
level: 5
./vendor/bin/phpstan analyse --memory-limit=2G
Where to look first, in this order:
| Doc | Why you care |
|---|---|
docs/features.md |
What magic is actually understood (relations, facades, models, views) |
docs/rules.md |
Laravel-specific rules you can toggle — many are opt-in |
docs/custom-types.md |
model-property<...> and friends for precise PHPDoc |
docs/custom-config-parameters.md |
checkModelProperties, viewDirectories, migration scan knobs |
docs/errors-to-ignore.md |
Copy-paste ignores for known-too-magical Laravel corners |
First use case to chase: an undefined/nonexistent model attribute or a wrong relation type — e.g. $user->proflie or $post->comments()->first()->body where comments() returns HasMany<Comment> and you forgot the ?. Those are the bugs that cost you a 3am production page and that Larastan finds in under a second.
For an existing codebase, don't start at level 5. Do:
./vendor/bin/phpstan analyse --generate-baseline
commit phpstan-baseline.neon, include it in phpstan.neon, and enforce the full level on new code only. That is the whole ratchet.
Ratchet the level per-directory instead of globally. Raise level one notch at a time, fix, commit, repeat. When app/ hits level 8 but database/ and routes/ are feral, split paths:
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
level: 0
paths: [app, database, routes, config]
ignoreErrors:
- identifier: missingType.generics
paths: [database/migrations/*]
CI job (the highest-leverage five lines you'll write this quarter):
- run: composer install --prefer-dist --no-interaction # NOT --no-dev
- run: ./vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=2G
--error-format=github turns findings into inline PR annotations, which is what actually makes developers fix them. Add the same command to a composer lint / pre-commit hook for the fast local loop.
Model typing is the core daily workflow. Larastan infers column types from your migrations, but relations need explicit generics in v3 (the automatic relation generics from 2.x were dropped — read UPGRADE.md before upgrading):
/**
* @property int $id
* @property string $email
* @property-read Carbon $created_at
* @property-read Collection<int, Post> $posts
*/
class User extends Model
{
/** @return HasMany<Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
}
That one @return HasMany<Post, $this> propagates through ->posts()->where(...)->get() and gives you Collection<int, Post> end to end. $this as the second template arg keeps fluent chaining accurate.
Turn on full model property checking only where it pays:
parameters:
checkModelProperties: true
databaseMigrationsPath: [database/migrations]
This resolves attributes from the actual schema rather than @property docblocks — powerful, but slow and requires a reachable database, so keep it out of the default local run and put it in a nightly CI job.
Precise types at the boundaries. Larastan gives you helpers to stop mixed leaking out of Eloquent and requests:
/** @param model-property<User> $email */
function sendTo(string $email): void {}
/** @var array{name: string, email: string} $data */
$data = $request->validate([...]);
Prefer error identifiers over message regexes (PHPStan 2 / Larastan 3 support both):
$foo->bar(); // @phpstan-ignore method.notFound
ignoreErrors:
- identifier: larastan.noUnnecessaryCollectionCall
count: 3 # fails if the count changes — keeps ignores from rotting
count: on an ignore is underused: it makes an ignored error a tracked debt rather than a permanent blind spot.
Package development: install orchestra/testbench and point Larastan at it, otherwise the container boot fails and you get a wall of nonsense.
Extension points: add your own stubFiles to teach PHPStan about local magic; write custom PHPStan\Rules\Rule implementations for house conventions (no DB::raw, no env() outside config); and use larastan's existing extensions as templates — src/Extensions/ is the best-documented part of the codebase.
It boots your app — this is not pure static analysis. That means .env must exist, APP_KEY must be set, and config must be consistent. The number-one false-positive source is a stale bootstrap/cache/config.php: run php artisan config:clear before phpstan analyse in CI, or you'll analyse yesterday's config forever.
Database access is conditional but real. Migration scanning needs to parse migrations, not connect — but checkModelProperties and anything resolving the schema does connect. If your CI has no DB service, either add one or leave checkModelProperties off in that job.
Memory. Default PHP memory limits die on mid-size apps. Always pass --memory-limit=2G (or -1 in CI). Also cap parallelism: parallel: maximumNumberOfProcesses: 4 prevents a 16-core CI runner from OOMing itself.
Squashed migrations are a trap. Larastan reads migrations to infer column types; if you squash old migrations into schema:dump, the original Schema::create calls vanish. Either keep them in database/schema/ and set squashedMigrationsPath, or accept degraded inference. Raw DB::statement migrations are invisible to it entirely — annotate those models with @property by hand.
env() return types are generalized to string|null by default. If you're comparing env('X') === true, that's a bug and Larastan will tell you. You can opt out, but don't — move the call into config/ instead. That's the correct Laravel pattern anyway.
$request->input() is mixed, $request->validated() is array. Both are honest. Widen them with @var shapes or FormRequest rules() inference rather than reaching for assert().
Ignoring is a one-way door if you're sloppy. @phpstan-ignore-next-line attaches to the next line and silently detaches when someone inserts a comment above it. Prefer inline @phpstan-ignore <identifier> on the same line, and in config prefer identifier: + count: over '#Call to an undefined method .*badMethod\(\)#'. Regex ignores with line numbers are the classic thing that breaks on every refactor.
Level 3 → 6 is where the real value is; level 10 is a lifestyle choice. max forces mixed everywhere (checkExplicitMixed), which on a framework full of __get is often performable fiction. Level 6–8 is the sane target for application code.
Version pinning matters. Larastan tracks PHPStan majors tightly: 3.x requires PHPStan 2 and Laravel 11.15+. If you pin phpstan/phpstan too loosely you'll get an extension/API mismatch. Pin both, upgrade together, and read both upgrade guides. Also note the 3.9.5 fix for an infinite loop when the Redis extension is missing — pin >=3.9.5 if you have Redis in config/database.php but not installed locally.
Don't analyse vendor/, and don't composer install --no-dev in the analysis job — you'll get "class not found" avalanches from dev-only stubs and test helpers.
Facades you wrote yourself need @method docblocks on the class; Larastan only knows about Laravel's own facades out of the box.
It doesn't replace tests. It finds type errors, dead code, wrong-collection-method misuse, missing translations, unused Blade views — not business logic errors. The teams that get value from it treat it as a fast pre-test filter, not a substitute.
How can I help you explore Laravel packages today?