PHPStan in Practice: Static Analysis for Laravel Projects
PHPStan catches bugs before they reach production — without running a single test. For Laravel projects, Larastan extends PHPStan to understand Eloquent magic, facades, and container bindings. This guide covers real-world setup, not just "install and run level 5."
Why Static Analysis Matters
Types of bugs PHPStan catches that tests often miss:
// 1. Calling method on possibly null
$user = User::find($id);
$user->notify(new WelcomeEmail()); // PHPStan: Method called on null
// 2. Wrong argument types
Cache::put('key', $value, 'not-a-number'); // PHPStan: expects int|DateTimeInterface
// 3. Dead code
if ($status === 'active' && $status === 'inactive') { // Always false
// This code never runs
}
// 4. Missing return types causing downstream bugs
function getPrice() { // Returns int|float — callers assume int
return $item->price * 1.1;
}
Installation
composer require --dev larastan/larastan
Larastan includes PHPStan — don't install both separately.
Configuration
phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
parameters:
paths:
- app/
level: 6
excludePaths:
- app/Console/Commands/
ignoreErrors: []
checkMissingIterableValueType: false
checkGenericClassInNonGenericObjectType: false
Understanding Levels
| Level | What it checks | Recommended for |
|---|---|---|
| 0 | Basic checks, unknown classes | Legacy codebases |
| 1 | Possibly undefined variables | Starting out |
| 2 | Unknown methods on $this |
New projects |
| 3 | Return types | Active development |
| 4 | Dead code, unreachable | Growing projects |
| 5 | Argument types | Good default |
| 6 | Missing typehints | Recommended |
| 7 | Union types strictly | Strict projects |
| 8 | Nullable operations | Strict projects |
| 9 | Mixed type strictness | Maximum strictness |
Start at level 5-6 for new projects. For existing projects, use baselines.
Running PHPStan
# Analyze entire project
vendor/bin/phpstan analyse
# Analyze specific paths
vendor/bin/phpstan analyse app/Services/ app/Http/Controllers/
# With memory limit for large projects
vendor/bin/phpstan analyse --memory-limit=512M
# Generate baseline (for existing projects)
vendor/bin/phpstan analyse --generate-baseline
# Clear cache after config changes
vendor/bin/phpstan clear-result-cache
The Baseline Strategy
For existing projects, you can't fix 500 errors at once. Baselines let you start enforcing rules on new code immediately:
# Generate baseline file
vendor/bin/phpstan analyse --generate-baseline
This creates phpstan-baseline.neon:
# phpstan.neon — include the baseline
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
level: 6
paths:
- app/
Now PHPStan ignores existing errors but catches all new ones. Chip away at the baseline over time:
# See how many baseline errors remain
grep -c "message:" phpstan-baseline.neon
# After fixing some errors, regenerate
vendor/bin/phpstan analyse --generate-baseline
Common Laravel Issues and Fixes
1. Eloquent Model Properties
PHPStan doesn't know about dynamic Eloquent attributes:
// Error: Access to an undefined property App\Models\Post::$title
$post = Post::first();
echo $post->title;
Fix: Add @property PHPDoc to models:
/**
* @property int $id
* @property string $title
* @property string $slug
* @property string $content
* @property bool $is_published
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, Tag> $tags
*/
class Post extends Model
{
// ...
}
Or use the IDE helper generator:
composer require --dev barryvdh/laravel-ide-helper
php artisan ide-helper:models --write
2. Nullable find() Results
The most common Laravel PHPStan error:
// Error: Called method on null
$user = User::find($id);
$user->name; // $user could be null!
Fixes:
// Option 1: findOrFail — throws 404 if not found
$user = User::findOrFail($id);
$user->name; // Safe — PHPStan knows it's User
// Option 2: Null check
$user = User::find($id);
if ($user === null) {
abort(404);
}
$user->name; // Safe after null check
// Option 3: Type assertion (when you're sure)
/** @var User $user */
$user = User::find($id);
3. Collection Generic Types
// Error: Method get() returns mixed
$posts = Post::where('published', true)->get();
// PHPStan knows this returns Collection<int, Post>
// but chained operations may lose type info
$titles = $posts->map(fn ($post) => $post->title);
// $titles is Collection<int, mixed> without return type hint
Fix with PHPDoc:
/** @return Collection<int, string> */
public function getPublishedTitles(): Collection
{
return Post::where('published', true)
->get()
->map(fn (Post $post): string => $post->title);
}
4. Facade Magic Methods
Larastan handles most facades, but custom macros need help:
// If you added a macro:
Str::customMethod('test'); // Error: Call to undefined method
// Fix: Add to a stub file
// stubs/macros.stub
namespace Illuminate\Support;
/**
* @method static string customMethod(string $value)
*/
class Str {}
# phpstan.neon
parameters:
stubFiles:
- stubs/macros.stub
5. Request Validation Data
// Error: $validated is array<string, mixed>
$validated = $request->validated();
$name = $validated['name']; // Type is mixed
// Fix 1: Cast after validation
$name = (string) $validated['name'];
// Fix 2: Use FormRequest with @return PHPDoc
/**
* @return array{name: string, email: string, age: int}
*/
public function validated($key = null, $default = null): array
{
return parent::validated($key, $default);
}
// Fix 3: Use a DTO
$dto = PostData::from($request->validated());
$dto->name; // PHPStan knows it's string
6. Config and Environment Values
// Error: config() returns mixed
$appName = config('app.name');
strlen($appName); // Mixed passed to strlen
// Fix: Explicit cast
$appName = (string) config('app.name');
// Or type-safe helper
function configString(string $key, string $default = ''): string
{
$value = config($key);
return is_string($value) ? $value : $default;
}
Custom PHPStan Rules
Disallow dd() and dump() in Production Code
// app/PHPStan/Rules/NoDebugStatementsRule.php
namespace App\PHPStan\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<FuncCall>
*/
class NoDebugStatementsRule implements Rule
{
private const FORBIDDEN_FUNCTIONS = ['dd', 'dump', 'ray', 'var_dump', 'print_r'];
public function getNodeType(): string
{
return FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof \PhpParser\Node\Name) {
return [];
}
$functionName = $node->name->toString();
if (in_array($functionName, self::FORBIDDEN_FUNCTIONS, true)) {
return [
RuleErrorBuilder::message(
sprintf('Call to %s() is not allowed in production code.', $functionName)
)->build(),
];
}
return [];
}
}
Register it:
# phpstan.neon
services:
- class: App\PHPStan\Rules\NoDebugStatementsRule
tags:
- phpstan.rules.rule
Enforce Service Return Types
// app/PHPStan/Rules/ServiceMethodReturnTypeRule.php
namespace App\PHPStan\Rules;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<ClassMethod>
*/
class ServiceMethodReturnTypeRule implements Rule
{
public function getNodeType(): string
{
return ClassMethod::class;
}
public function processNode(Node $node, Scope $scope): array
{
$className = $scope->getClassReflection()?->getName() ?? '';
if (!str_contains($className, '\\Services\\')) {
return [];
}
if ($node->name->toString() === '__construct') {
return [];
}
if ($node->returnType === null) {
return [
RuleErrorBuilder::message(
sprintf(
'Public method %s::%s() must have a return type declaration.',
$scope->getClassReflection()->getName(),
$node->name->toString()
)
)->build(),
];
}
return [];
}
}
CI/CD Integration
GitHub Actions
# .github/workflows/static-analysis.yml
name: Static Analysis
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
phpstan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- name: Install dependencies
run: composer install --no-progress --prefer-dist
- name: Run PHPStan
run: vendor/bin/phpstan analyse --error-format=github
The --error-format=github flag adds inline annotations to PRs.
Pre-commit Hook
#!/bin/sh
# .git/hooks/pre-commit
CHANGED_PHP_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
if [ -n "$CHANGED_PHP_FILES" ]; then
echo "Running PHPStan on changed files..."
vendor/bin/phpstan analyse $CHANGED_PHP_FILES
if [ $? -ne 0 ]; then
echo "PHPStan found errors. Fix them before committing."
exit 1
fi
fi
Combining with Pint
Run both tools in sequence for a complete code quality check:
// composer.json
{
"scripts": {
"lint": "vendor/bin/pint --test",
"analyse": "vendor/bin/phpstan analyse",
"check": [
"@lint",
"@analyse"
]
}
}
composer check
Advanced Configuration
Per-Path Level Overrides
Stricter rules for new code, relaxed for legacy:
parameters:
level: 6
paths:
- app/
# Stricter for services
# Relaxed for legacy controllers
ignoreErrors:
-
message: '#Parameter .* has no type declaration#'
paths:
- app/Http/Controllers/Legacy/
Type Coverage Tracking
Track how much of your codebase is typed:
composer require --dev tomasvotruba/type-coverage
vendor/bin/phpstan analyse --error-format=json | \
php vendor/bin/type-coverage
PHPStan Extensions Worth Adding
# Strict rules — catches more issues
composer require --dev phpstan/phpstan-strict-rules
# Deprecation warnings
composer require --dev phpstan/phpstan-deprecation-rules
# PHPUnit assertions
composer require --dev phpstan/phpstan-phpunit
includes:
- vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
- vendor/phpstan/phpstan-phpunit/extension.neon
Real-World Workflow
Here's the workflow that works in practice:
- New project: Start at level 6, zero baseline
- Existing project: Start at level 6, generate baseline
- Every PR: PHPStan must pass (GitHub Actions)
- Weekly: Fix 5-10 baseline errors
- Monthly: Try bumping the level, generate new baseline
- Goal: Level 8+ with zero baseline errors
# Quick check during development
vendor/bin/phpstan analyse app/Services/MarkdownPostService.php
# Full check before pushing
composer check
# Reduce baseline over time
vendor/bin/phpstan analyse --generate-baseline
git diff phpstan-baseline.neon # See progress
Measuring Progress
Track your PHPStan journey:
# Count current baseline errors
grep -c "message:" phpstan-baseline.neon
# Track over time in your README
# Week 1: Level 5, 342 baseline errors
# Week 4: Level 6, 189 baseline errors
# Week 8: Level 6, 47 baseline errors
# Week 12: Level 7, 12 baseline errors
Summary
| Aspect | Recommendation |
|---|---|
| Starting level | 5-6 for new, baseline for existing |
| Larastan | Always use — understands Laravel magic |
| Baseline | Essential for gradual adoption |
| CI | Block PRs that introduce new errors |
| Custom rules | No dd/dump, enforce return types |
| IDE helper | Generate model PHPDocs automatically |
| Goal | Level 8+ with clean baseline |
PHPStan isn't about reaching level 9 on day one. It's about preventing regressions while gradually improving type safety. Every error caught by PHPStan is one fewer bug in production.