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:
- The baseline may only ever shrink. Enforce it by checking the file's error count in CI if you want teeth.
- 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-runfails 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.