Static Analysis: The Bugs You Catch Before Writing a Single Test
Every test you write costs effort, per case, forever. Static analysis works differently: it reads the code and flags whole categories of mistakes across every file at once. A nullable return used where the code below assumes a value. An argument passed in the wrong order. A method that stopped existing three refactors ago. The two approaches don't compete. Analysis covers the mechanical errors so tests can spend their budget on behavior, and on every Laravel project we start, analysis is wired in during the first week.
What it catches that review won't
A reviewer reads a diff; a static analyzer reads every path. The recurring finds:
- Nullable values. A
?->chain feels safe while the code three lines down assumes the call returned something. - Array keys and shapes:
'usr_id'versus'user_id', an array passed where a collection is expected. - Dead code and unreachable branches left behind by a refactor.
- Swapped arguments in your own helpers when both parameters happen to be strings.
None of these are clever bugs, which is exactly why they survive every code review: the code looks right at reading speed, and the analyzer reads it differently.
Start low, ratchet up, never move backward
We adopt PHPStan with Larastan, which teaches the analyzer about Eloquent and framework magic:
composer require --dev larastan/larastan
vendor/bin/phpstan analyse --level=1
Then two rules hold the line:
- New code must pass at the chosen level, enforced in CI.
- A baseline file records pre-existing violations so they don't block. The baseline only ever shrinks, and
--generate-baselineis re-run when the level rises, never to make new code pass.
The ratchet matters more than the level number. Teams that jump straight to --level=max on an existing codebase spend a week on trivia and abandon the tool. Teams that never enforce it produce a config file nobody runs. "Nothing new" plus a slowly shrinking debt list is the version that sticks.
The Laravel layer
Plain PHPStan drowns in Eloquent: User::find() returning ?User, dynamic query scopes, relation properties. Larastan knows those shapes. Two habits keep the results honest:
- Type your models and collections with docblocks and generics (
Collection<int, Order>). Ten minutes per class, and half the "might be null" warnings turn into real findings. - Ignore lines only with a reason written next to the ignore. Bare ignores multiply quietly; annotated ones are decisions someone can revisit.
The frontend gets the same treatment: TypeScript in strict mode and vue-tsc in CI, with ESLint warnings treated as errors. In an Inertia app the props crossing the backend-to-frontend boundary are the shared type surface, so declare the shapes once and keep both sides honest.
Where it starts and where it stops
Static analysis reads types, not intent. The discount that shouldn't go negative, the race condition when two users buy the last item, the query that runs fine until the table has a million rows: those belong to tests. We see the split as embarrassments versus expenses. Analysis catches the errors that are embarrassing, tests catch the errors that are expensive, and you want both running on the same CI gate.
The good news about the first one: it costs an afternoon. Install the package, generate a baseline, add one CI step. The bugs it finds on a typical codebase pay for that afternoon before the second PR.