Why AI-written Laravel code passes review and fails in production
An N+1 in a Blade loop, a job with no retry policy of its own, request input straight into create(): the Laravel defects a generic AI rule never catches.
Agenova Editorial7 min read
The code was fine on a laptop
Ask an AI assistant for a Laravel feature and the first draft usually reads well. The controller is short, the job is tidy, the Blade template renders. A reviewer skims it, finds nothing to object to, and approves it.
The trouble is that the defects that hurt a Laravel application are rarely visible in the diff. They depend on how much data the table holds, on what the queue does when an external API returns an error, on which fields a crafted request can set. None of that shows up in a manual test against a seeded local database, and none of it is prevented by an instruction such as "write clean, idiomatic Laravel code". A model given that sentence produces confident, plausible code, and plausible is exactly the problem.
Four defects that only appear in production
The query that grows with the data. @foreach ($orders as $order) {{ $order->customer->name }} @endforeach issues one extra query per row unless the relation was eager loaded with with('customer'). With twenty rows nobody notices. The page simply gets slower as the table grows, and no single commit is to blame.
The input nobody filtered. Model::create($request->all()) looks harmless when the form has three fields. But a request can carry any field, and if role or user_id sits in $fillable, the client now controls it. The form not having a role input is not a defence.
The job that retries on someone else's terms. A queued job with no $tries and no $backoff falls back to whatever retry policy the queue worker was started with, and keeps being replayed against an API that is down until the worker gives up. If its side effect — an email, a charge, a write to another system — happens before the failing call, every retry repeats the side effect.
The dispatch inside the transaction. Dispatching a job inside DB::transaction() means a worker can pick it up before the commit. It then loads a row that does not exist yet and throws ModelNotFoundException, under load and never on a laptop. afterCommit() is the fix, and it is a one-word change nobody makes unless they have been bitten.
What these four have in common: the code is correct for the case the author tested, and wrong for the case production supplies. A reviewer checks whether code is right; they are much worse at imagining the data it will meet.
Why a generic rule does not catch them
General-purpose AI coding rules are written to apply to any language and any framework. That is what makes them weak here. "Avoid performance problems" does not tell a model that a relation accessed in a loop is an N+1 query. "Validate input" does not tell it that validated() and a Form Request are the Laravel answer, and that $request->all() into fill() is the mistake.
A rule changes the output when it names the failure in the framework's own terms. Compare "write secure code" with "$request->all() into fill() is a mass-assignment vulnerability; use a Form Request and validated()". The first produces a comment saying the code is secure. The second produces a Form Request.
What a framework-specific rule looks like
The rules that work share three properties.
- They open with the bug. A rule that starts with the defect it prevents gives the model a concrete pattern to avoid, and gives a reviewer something to point at. "A job without
$triesretries on the worker's default policy" is checkable on a diff; "handle errors gracefully" is not. - They are scoped by path. A Blade edit does not need queue guidance in its context, and a migration does not need validation rules. Attaching a rule only to the files where its failure can happen keeps the always-on part small, so the model actually follows what is in front of it.
- They state the framework version. Advice that holds for one major version and not another is a source of confident, outdated code. A rule set should say what it was written against.
When you add a rule of your own, write the incident first and the rule second. If you cannot name the failure, the rule is a preference, and preferences dilute the rules that matter.
A practical check for your own codebase
Before adopting any rule set, look at the last few production incidents in your Laravel application and ask which of them a reviewer could have spotted from the diff alone. The ones they could not — a missing eager load, an unbounded retry, a dispatch before commit, a route group with a prefix but no auth middleware — are the ones worth writing down as rules, because they are the ones review will keep missing.
Then run the same request twice, once with your rules attached and once without, and compare the drafts. If the rules do not change the output on the cases above, they are not doing their job yet.
Where to start
Cursor Rules for Laravel packages this approach as seven scoped rules — Eloquent, validation, queues and jobs, Pest testing, authorization, migrations, and a small always-on baseline — each opening with the failure it prevents. It also ships ANTI-PATTERNS.md, the failures the rules were written from, and EXAMPLE.md, one request run with and without the rules. Only rules/00-laravel-baseline.mdc is always on; the others attach by path, so a Blade edit does not carry queue rules with it. The pack is written against Laravel 11 and 12.
If your team's problem is less about Laravel specifically and more about keeping one ruleset followed across many people, governing a Cursor ruleset past its author covers that side.
Put this into practice.
Cursor Rules for Laravel
This guide has a matching toolkit, built and maintained for production use.
$29.99
Open Cursor Rules for Laravel