+1 (415) 599-8902

Static Analysis for Laravel: Pint, Larastan and Rector in CI

Most Laravel codebases we are asked to rescue do not fail because of one bad decision. They fail because nothing ever stopped the small ones: an untyped array passed three layers deep, a facade call inside a model, a controller that grew to 700 lines, a Carbon::now() sprinkled through business logic. Reviews catch some of it. Tooling catches it every time, on every commit, for free.

This tutorial sets up a practical static-analysis and automated-refactoring pipeline for a Laravel 13 application: Pint for formatting, Larastan/PHPStan for type-level correctness, Rector for mechanical upgrades and refactors, and GitHub Actions to enforce all three. Crucially, it also shows how to introduce them into a legacy app that currently fails thousands of checks — without a six-month freeze.

You will need PHP 8.3+, a Laravel 11/12/13 app, and Composer.

Step 1: Pint, with an opinion

Pint ships with Laravel. The default laravel preset is fine, but pin it and add the rules that actually prevent bugs rather than just moving braces. Create pint.json in the project root:

{
    "preset": "laravel",
    "rules": {
        "declare_strict_types": true,
        "global_namespace_import": {
            "import_classes": true,
            "import_constants": false,
            "import_functions": false
        },
        "fully_qualified_strict_types": true,
        "no_unused_imports": true,
        "ordered_imports": {
            "sort_algorithm": "alpha"
        },
        "void_return": true
    }
}

declare_strict_types is the one that pays for itself. Without it, PHP silently coerces "12abc" into 12 at a function boundary and your validation layer gets to find out later.

Run it once across the codebase and commit that as a single, isolated commit:

./vendor/bin/pint
git add -A && git commit -m "style: apply Pint baseline"

Do this on its own branch, merge it before anything else, and tell the team. A whole-repo reformat mixed into a feature PR is how people learn to hate tooling. Some teams also record the commit hash in .git-blame-ignore-revs:

echo "$(git rev-parse HEAD)" >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs

From then on, git blame skips the reformat and still shows who wrote the logic.

Step 2: Larastan, and a baseline you are allowed to have

PHPStan does not understand Laravel's magic on its own — Model::where(), facades, container bindings, relation properties. Larastan teaches it.

composer require --dev larastan/larastan phpstan/phpstan-deprecation-rules

Create phpstan.neon:

includes:
    - vendor/larastan/larastan/extension.neon
    - vendor/phpstan/phpstan-deprecation-rules/rules.neon
    - phpstan-baseline.neon

parameters:
    level: 6
    paths:
        - app
        - config
        - database
        - routes
        - tests
    checkMissingIterableValueType: false
    checkOctaneCompatibility: true
    treatPhpDocTypesAsCertain: false

Then generate the baseline. This is the step that makes adoption realistic on a legacy app: every current violation is recorded and ignored, and only new violations fail the build.

touch phpstan-baseline.neon
./vendor/bin/phpstan analyse --generate-baseline

Commit the baseline. It will be large. That is fine — it is a debt ledger, not a defeat. Two rules keep it honest:

  1. The baseline may only ever shrink. Enforce it by checking the file's error count in CI if you want teeth.
  2. Any file you touch for a feature gets its baseline entries removed in that PR. Debt gets paid where the work is already happening.

Raising the level is a separate, scheduled exercise. Levels 5 and 6 catch real mistakes in most Laravel apps; levels 8 and 9 mostly catch missing nullability annotations, which is valuable but noisy. Go one level at a time, regenerate the baseline, and measure how many entries you added.

checkOctaneCompatibility: true is worth calling out: it flags the stateful-singleton patterns that break under Octane and FrankenPHP. If running long-lived workers is anywhere on your roadmap, turn it on now and fix the findings while they are cheap.

Typed generics for Eloquent

Larastan understands generic annotations on relations, and they eliminate a whole class of "method does not exist" false positives:

/**
 * @return \Illuminate\Database\Eloquent\Relations\HasMany<Invoice, $this>
 */
public function invoices(): HasMany
{
    return $this->hasMany(Invoice::class);
}

Laravel 12 and 13 ship these annotations in the framework, so a good chunk of your errors will disappear just by upgrading.

Step 3: Rector for the mechanical work

Rector applies AST-level refactors. Two use cases matter here: PHP/Laravel version upgrades, and enforcing house conventions automatically.

composer require --dev rector/rector driftingly/rector-laravel

Create rector.php:

<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;
use RectorLaravel\Set\LaravelLevelSetList;
use RectorLaravel\Set\LaravelSetList;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/app',
        __DIR__ . '/database',
        __DIR__ . '/routes',
        __DIR__ . '/tests',
    ])
    ->withSets([
        LevelSetList::UP_TO_PHP_83,
        LaravelLevelSetList::UP_TO_LARAVEL_110,
        LaravelSetList::LARAVEL_CODE_QUALITY,
        LaravelSetList::LARAVEL_IF_HELPERS,
        LaravelSetList::LARAVEL_ELOQUENT_MAGIC_METHOD_TO_QUERY_BUILDER,
        SetList::DEAD_CODE,
        SetList::TYPE_DECLARATION,
    ])
    ->withSkip([
        __DIR__ . '/app/Legacy',
    ])
    ->withImportNames(removeUnusedImports: true);

Always inspect before you apply:

./vendor/bin/rector process --dry-run

Then land it in slices — one set at a time, one commit per set, with the test suite green between each. DEAD_CODE and TYPE_DECLARATION are the two that produce the largest diffs; run them alone.

A hard-won caution: Rector is a refactoring tool, not a reviewer. It will happily convert a Model::where(...) chain into a query-builder call inside a method whose behaviour depended on a global scope, and it cannot know your intent. Never apply Rector to a codebase with a weak test suite. If coverage on the affected area is thin, write characterisation tests first — that is the same order of operations we use on rescue engagements, and it is not optional.

After Rector, always re-run Pint. Rector's output is syntactically correct but not style-consistent.

Step 4: One command for humans

Give the team a single entry point in composer.json so nobody has to remember flags:

{
    "scripts": {
        "lint": "pint --test",
        "fix": "pint",
        "analyse": "phpstan analyse --memory-limit=1G",
        "refactor": "rector process --dry-run",
        "test": "pest --parallel",
        "ci": [
            "@lint",
            "@analyse",
            "@test"
        ]
    }
}

Now composer ci reproduces the pipeline locally. Anything that fails in CI but cannot be reproduced with one command will be ignored by developers, and rightly so.

Step 5: Enforce it in GitHub Actions

name: Code Quality

on:
  pull_request:
  push:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
          tools: composer:v2

      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: composer-${{ hashFiles('composer.lock') }}

      - run: composer install --no-interaction --prefer-dist --no-progress

      - name: Pint
        run: ./vendor/bin/pint --test

      - name: PHPStan
        run: ./vendor/bin/phpstan analyse --error-format=github --memory-limit=1G

      - name: Rector
        run: ./vendor/bin/rector process --dry-run --no-progress-bar

      - name: Tests
        run: ./vendor/bin/pest --parallel

Two details that matter. --error-format=github makes PHPStan failures appear as inline annotations on the diff, which roughly doubles the chance someone fixes them instead of re-running the job. And caching PHPStan's result cache between runs cuts analysis time dramatically on large apps:

      - name: Cache PHPStan
        uses: actions/cache@v4
        with:
          path: ./tmp/phpstan
          key: phpstan-${{ github.sha }}
          restore-keys: phpstan-

Point tmpDir: tmp/phpstan at it in phpstan.neon.

Keep pint --test and rector --dry-run as checks, never as auto-commits from CI. Bots that push formatting commits to contributors' branches cause more confusion than they save, and they make signed-commit and branch-protection setups awkward.

Step 6: Fail fast locally

A pre-commit hook that only checks staged files keeps the loop fast:

composer require --dev brianium/paratest captainhook/captainhook
./vendor/bin/captainhook install

captainhook.json:

{
    "pre-commit": {
        "actions": [
            { "action": "./vendor/bin/pint --test --dirty" },
            { "action": "./vendor/bin/phpstan analyse --memory-limit=1G" }
        ]
    }
}

pint --dirty only inspects files changed against HEAD, so the hook stays under a couple of seconds. Resist adding the full test suite here; that belongs in CI, and slow hooks get bypassed with --no-verify within a week.

What good looks like after a month

  • Pint failures: zero, permanently, because the hook catches them before the push.
  • PHPStan baseline: smaller than last month, with the delta traceable to feature PRs.
  • Rector: no drift, because --dry-run fails the build the moment someone writes a pattern the config outlaws.
  • Code review: spent on domain logic and boundaries, not on import ordering.

The real return is not tidier code. It is that upgrades stop being projects. When Laravel 14 lands, an app with strict types, a shrinking baseline and a Rector config already in CI is a one-afternoon upgrade. An app without them is a quote.

Where this fits in a real engagement

On a legacy codebase we usually sequence it exactly as above: Pint baseline first (visible, zero-risk), then Larastan with a generous baseline, then characterisation tests around the areas Rector will touch, and only then the automated refactors. Trying to do it in the other order — Rector first, on an untested app — is the most common way these efforts get abandoned.

If you are staring at a Laravel application nobody wants to touch, or you need a second pair of hands to get a quality pipeline in place without stalling the roadmap, get in touch and describe the codebase. We do this work as a fixed-scope engagement, and we hand back the config files and the reasoning, not just a green build.