PHPStan in Practice: Static Analysis for Laravel Projects

· 8 min read

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:

  1. New project: Start at level 6, zero baseline
  2. Existing project: Start at level 6, generate baseline
  3. Every PR: PHPStan must pass (GitHub Actions)
  4. Weekly: Fix 5-10 baseline errors
  5. Monthly: Try bumping the level, generate new baseline
  6. 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.

Comments