Your tests pass. Your app runs. And somewhere in your codebase, a method is returning
mixedwhere it should return aUser, a property is accessed on something that might benull, and a style inconsistency has survived twenty pull requests. Static analysis finds these things before your users do.
PHPStan, Larastan, and Laravel Pint are three tools that, used together, give your Laravel application a permanent automated code reviewer — one that runs in milliseconds, never gets tired, and never misses a null dereference. The problem is that getting them configured without drowning in false positives, cryptic errors, and CI noise has historically required an afternoon of documentation spelunking.
This guide cuts through that. You’ll walk away with a working setup, a sensible configuration, and the knowledge to level it up incrementally.
The Three Tools and What They Each Do
Before configuring anything, it’s worth being precise about what each tool is solving — because they are complementary, not overlapping.
| Tool | What it does |
|---|---|
| PHPStan | Static analysis engine. Analyses your PHP code without running it, catching type errors, undefined variables, dead code, and logic bugs at the level of the type system. |
| Larastan | A PHPStan extension for Laravel. Teaches PHPStan to understand Eloquent models, facades, service container bindings, relationships, and magic Laravel patterns. |
| Laravel Pint | An opinionated PHP code style fixer built on PHP-CS-Fixer. Formats your code to a consistent style automatically. No arguments about tabs vs spaces ever again. |
Think of them in layers: Pint handles how your code looks. PHPStan handles whether your code is correct. Larastan handles whether your Laravel-specific code is correct. All three together give you automated confidence at every level of code quality.
Installing the Tools
# Larastan includes PHPStan as a dependency — one install gets both
composer require nunomaduro/larastan --dev
# Pint ships with Laravel 9+ — install standalone if needed
composer require laravel/pint --dev
That’s it. No build steps, no webpack configuration, no additional runtimes. Both tools are pure Composer packages that are ready to run the moment they’re installed.
Configuring PHPStan and Larastan
PHPStan is configured via a phpstan.neon file in your project root. The key decisions are which directories to analyse, which level to run at, and how to extend it with Larastan’s Laravel-aware rules.
# phpstan.neon
includes:
- vendor/nunomaduro/larastan/extension.neon
parameters:
paths:
- app
- tests
# Start at level 5. Work up to 8 or 9 over time.
level: 5
# Declare your PHP version for accurate analysis
phpVersion: 80300
# Suppress errors you can't fix yet (use sparingly)
ignoreErrors:
- '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#'
About levels: PHPStan has 10 levels (0–9). Level 0 catches the most obvious errors. Level 9 enforces strict type safety throughout. Starting at level 5 on an existing codebase is the right balance — strict enough to be useful, lenient enough to not generate hundreds of errors on day one.
Running PHPStan
# Run static analysis
./vendor/bin/phpstan analyse
# Run with a specific level (overrides phpstan.neon)
./vendor/bin/phpstan analyse --level=6
# Generate a baseline for existing errors
./vendor/bin/phpstan analyse --generate-baseline
The Baseline: Your Secret Weapon for Existing Codebases
The biggest reason teams abandon PHPStan on existing Laravel projects is the initial error count. Enable analysis on a mature codebase and you might see 200, 500, even 2000 errors on first run. That’s demoralising — and not what you want your team’s first experience of static analysis to be.
The baseline feature is the answer. It snapshots all current errors and tells PHPStan to ignore them going forward — while still catching any new errors introduced from that point on.
# Step 1: Generate phpstan-baseline.neon with all current errors
./vendor/bin/phpstan analyse --generate-baseline
# phpstan.neon — with baseline included
includes:
- vendor/nunomaduro/larastan/extension.neon
- phpstan-baseline.neon # ← existing errors suppressed here
parameters:
level: 5
paths:
- app
- tests
From this point on, CI passes on existing code while failing on any new errors introduced. Commit the baseline file to version control. Reduce it over time as you fix legacy issues. This is the only sustainable path to adopting static analysis on a live project.
Workflow tip: When a developer clears a whole file’s worth of errors, they delete those lines from the baseline and commit it. Make shrinking the baseline a visible, celebrated act — it is genuine technical debt repayment with a measurable paper trail.
What Larastan Actually Understands
Without Larastan, PHPStan would flag nearly every Eloquent query, every facade call, and every magic method as an error. Larastan adds deep Laravel-specific type awareness so the analysis is actually useful instead of just noisy.
Eloquent Model Properties
class User extends Model
{
protected $casts = [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean',
];
}
// Larastan knows $user->is_admin is bool, not mixed
$user = User::findOrFail(1);
if ($user->is_admin) { // ✓ no PHPStan error
// ...
}
Query Builder Return Types
// Collection<int, User>
$users = User::where('active', true)->get();
// User (throws ModelNotFoundException if not found)
$user = User::findOrFail($id);
// User|null
$user = User::find($id);
// Without Larastan, all of the above would be typed as mixed
Relationships
class Post extends Model
{
/** @return BelongsTo<User, Post> */
public function author(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return HasMany<Comment, Post> */
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
// Larastan now knows:
// $post->author → User|null
// $post->comments → Collection<int, Comment>
Common PHPStan Errors in Laravel (and How to Fix Them)
1. “Call to an undefined method …Builder”
This happens when you define custom query scopes on a model. PHPStan doesn’t see the dynamic method at the class level. Fix it with @method docblocks.
/**
* @method static Builder|User active()
* @method static Builder|User whereRole(string $role)
*/
class User extends Model
{
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
}
2. “Property does not exist on Model”
PHPStan needs to know your model’s dynamic properties. Add PHPDoc @property annotations directly on the class.
/**
* @property int $id
* @property string $name
* @property string $email
* @property bool $is_admin
* @property Carbon|null $email_verified_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class User extends Model
{
// ...
}
3. “Function config() return type is mixed”
Helper functions like config(), env(), and request() return mixed because they genuinely can return anything. Fix this by narrowing the type at the call site.
// ✗ PHPStan flags this as mixed
$name = config('app.name');
// ✓ Cast explicitly
$name = (string) config('app.name');
// ✓ Or use assert() for stricter enforcement
$name = config('app.name');
assert(is_string($name));
Laravel Pint: Formatting Without Arguments
Pint is Laravel’s official code style fixer. It wraps PHP-CS-Fixer with Laravel’s own opinionated defaults so you get a consistent style that matches the framework’s own codebase — with zero configuration required to get started.
# Fix all files
./vendor/bin/pint
# Dry run — see what would change without touching files
./vendor/bin/pint --test
# Fix a specific directory
./vendor/bin/pint app/Models
# Verbose output — see each changed rule
./vendor/bin/pint -v
Configuring Pint
Pint works out of the box, but when you want to customise rules or switch presets, add a pint.json to your project root:
{
"preset": "laravel",
"rules": {
"concat_space": {
"spacing": "one"
},
"ordered_imports": {
"sort_algorithm": "alpha"
},
"no_unused_imports": true,
"not_operator_with_successor_space": true
},
"exclude": [
"bootstrap/cache",
"storage"
]
}
Available presets are laravel (default), psr12, and symfony. For most Laravel projects, the laravel preset with minor customisations covers everything you need.
Recommended workflow: Run
./vendor/bin/pint --testin CI and./vendor/bin/pintlocally before committing. CI fails on unformatted code; developers fix it with one command. Clean and simple.
Wiring Everything Into CI
The full value of these tools is realised when they run automatically on every pull request. Here’s a complete GitHub Actions workflow that runs PHPStan and Pint in parallel.
# .github/workflows/quality.yml
name: Code Quality
on: [push, pull_request]
jobs:
phpstan:
name: PHPStan Analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/phpstan analyse --no-progress
pint:
name: Laravel Pint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/pint --test
Why separate jobs? Running PHPStan and Pint as separate CI jobs means they execute in parallel and fail independently. A formatting issue doesn’t hide a type error, and a type error doesn’t prevent you from seeing which files need reformatting.
Composer Scripts: Make It One Command Away
The fastest way to ensure these tools actually get used locally is to reduce the friction to a single command.
{
"scripts": {
"analyse": "./vendor/bin/phpstan analyse",
"format": "./vendor/bin/pint",
"format:check": "./vendor/bin/pint --test",
"quality": [
"@format:check",
"@analyse"
]
}
}
# Format code locally
composer format
# Run static analysis
composer analyse
# Run both checks exactly as CI does
composer quality
Levelling Up: From Level 5 to Level 9
Starting at level 5 and incrementally raising it is the recommended path. Here’s what each level gain typically demands in a real Laravel codebase:
Level 5 → Basic type checking, undefined variables, dead code
Level 6 → Check return types of all called methods
Level 7 → Report always-true/false conditions, missing return types
Level 8 → Report nullable type mismatches, union type narrowing
Level 9 → Strict mixed — every $mixed usage becomes an error
The jump from level 8 to level 9 is steep. Level 9 requires explicit handling of every mixed value — which in a typical Laravel app means dozens of annotations on service container interactions, config values, and request data. It is worth reaching, but treat it as a months-long goal, not a weekend task.
Don’t chase level 9 in a single PR on a live codebase. The sustainable approach: fix errors at the current level, raise by one, add the new errors to the baseline, fix them incrementally over the next few sprints. Progress over perfection.
A Complete Working Setup at a Glance
# 1. Install both tools
composer require nunomaduro/larastan laravel/pint --dev
# 2. Create phpstan.neon
cat > phpstan.neon << 'EOF'
includes:
- vendor/nunomaduro/larastan/extension.neon
parameters:
level: 5
paths:
- app
- tests
EOF
# 3. Generate a baseline to silence existing errors
./vendor/bin/phpstan analyse --generate-baseline
# 4. Add "- phpstan-baseline.neon" to the includes in phpstan.neon
# 5. Format everything with Pint
./vendor/bin/pint
# 6. Commit — CI will catch every new error from here on
git add . && git commit -m "chore: add PHPStan, Larastan, and Pint"
Final Thoughts
PHPStan, Larastan, and Pint are not about perfectionism. They are about removing an entire category of bugs from production — type errors, null dereferences, undefined properties, inconsistent formatting — at a cost of milliseconds per commit rather than hours of debugging per incident.
The baseline strategy means there is no excuse to delay adoption on existing codebases. The incremental level system means there is no pressure to fix everything at once. The CI integration means the benefits compound automatically — every new line of code is held to the standard you’ve set, without anyone having to remember to check.
Set this up once. Let it run forever. Your future self — tracing a null pointer exception in production at midnight — will be grateful for the type checks you put in place today.
