Controlling Code Quality When an Agent Writes Your Laravel
Specs, ADRs and path-scoped rules. The three layers of context I build around an agent so it writes Laravel the way my codebase does, not the way every tutorial does.
The complaint I hear most often about agentic coding is not that the code is broken. It is that the code works, the tests pass, and the pull request still feels wrong. A fat controller here, a Request $request there, validation inline because it was quicker, a back()->with('success', 'Saved!') where the codebase would have returned a redirect to a named route with a flash key the layout actually reads.
That gap has nothing to do with model capability. A modern model knows Laravel extremely well. It knows Laravel the way the documentation and fifteen years of blog posts describe it, which is not the same as knowing Laravel the way your codebase does. Left alone it will write the average of every Laravel tutorial ever published, and that average is a resource controller with seven public methods and validation in the body.
So the work is not in the prompt. The work is in building enough context around the agent that the wrong answer is harder to reach than the right one. I do that in three layers, and each layer exists to answer a different question. A spec describes what we are building. An ADR records why the codebase is shaped the way it is. Rules tell the agent how to write a specific kind of file, and only load when it is about to touch one.
Why three layers and not one big file
The obvious move is to put everything into CLAUDE.md or AGENTS.md. I have done that on plenty of projects, and it holds up until the file gets long.
The problem is that a single instruction file has no sense of scope. It loads in full at the start of every session, whether you are editing a migration, a console command, or a controller. Give it a year and it is five pages, most of which has nothing to do with what you are working on today, and the agent has to work out which paragraphs apply before it can do anything useful. Instructions near the bottom get ignored. You add emphasis, you add capitals, you write IMPORTANT three times, and none of it helps, because the file is competing with itself.
Splitting the context by purpose fixes that. A spec is short-lived and covers one piece of work. An ADR is permanent and covers one decision. A rule is scoped to a path and only appears when the agent is about to edit a file that matches. Nothing loads that does not need to.
Layer one: the spec
A spec is what I write before any code exists, and it is deliberately not a design document. I am not describing classes. I am describing behaviour, boundaries, and what finished looks like.
Here is the shape I use, kept in docs/specs/:
# Spec: Team invitations
## Problem
Team owners can add members by email, but only if that person already hasan account. There is no way to invite somebody who has not signed up yet.
## Behaviour
- An owner submits an email address and a role from the team settings page.- We create a pending invitation and email a signed, expiring URL.- The invitee follows the link and either signs in or registers, then joins the team with the role recorded on the invitation.- Invitations expire after seven days.- Re-inviting an address that already has a pending invitation refreshes the expiry rather than creating a second record.
## Boundaries
- No bulk invites in this pass.- No revocation UI yet, that is a later story.- Roles are limited to the existing owner, admin and member set.
## Routes
| Method | URI | Name | Middleware || ------ | ------------------------- | ----------------------- | --------------- || POST | /teams/{team}/invitations | teams.invitations.store | auth || GET | /invitations/{invitation} | invitations.show | signed || POST | /invitations/{invitation} | invitations.accept | signed, auth |
## Acceptance
- An owner inviting somebody already on the team sees a validation error rather than creating a duplicate invitation.- A member cannot invite anyone.- An expired or tampered link renders the expired state rather than throwing.- Accepting while signed in as a different user does not silently join the wrong account to the team.Two things in there earn their place. The first is the route table. Routes are the public shape of a feature, and if I leave them to the implementation I get /team/invite, /teams/{id}/invite-user and invitation.create all inside the same session. Fixing the URI, the method, the name and the middleware up front removes a whole category of drift, and it means the signed middleware on the invitation links is a decision I made rather than something the agent either remembered or did not.
The second is the acceptance list, because those lines become tests. I want the agent writing tests against behaviour I described, not behaviour it inferred from code it wrote ten minutes ago. Tests generated from an implementation only ever prove that the implementation does what it does.
Layer two: the ADR
Specs get archived once the work ships. Decisions outlive them, and they are the layer I lean on hardest when an agent starts being helpful.
Agents are reasonable, which sounds like a good thing until you watch one reason its way around a convention. If you tell it a controller should be invokable without telling it why, it will follow that for a while and then quietly add a second method the moment the alternative looks like more files. Give it the reason and the reason holds under pressure.
These live in docs/decisions/, numbered, never edited once accepted, superseded by a new one when the decision changes.
# ADR-0004: Controllers are invokable and do one thing
## Status
Accepted
## Context
Resource controllers group seven unrelated actions behind one class name.The constructor becomes a dumping ground for every dependency any of theseven might need, authorisation gets applied with middleware in theconstructor rather than at the point of use, and diffs on a busy controllertouch code paths the change has nothing to do with.
## Decision
Every controller is final and readonly, has a single `__invoke` method, andis named after the thing it does rather than the resource it belongs to.Controllers live in a directory per domain concept.
## Consequences
- More files. That is the cost and we accept it.- Constructor injection describes exactly one action's dependencies.- Route definitions read as a list of behaviours.- `Route::resource` is not used anywhere in this application.I keep one ADR per decision, and for routes, requests, controllers and responses that usually comes to four or five: invokable controllers, form requests as the only place validation lives, actions for anything with a side effect, how responses leave a controller, and how routes are grouped and named.
The response one is the decision agents break most often, so it gets written down in the most detail:
# ADR-0006: Controllers return redirects to named routes
## Status
Accepted
## Context
`back()`, `redirect()->back()` and inline `with()` chains scattered flashmessage keys across the codebase with no consistency. Three controllers usedthree different session keys for the same success state, and the layoutended up reading all three.
## Decision
Controllers return a redirect to a named route. Flash messaging goes througha single helper with a fixed key set. `back()` is not used, because it truststhe referer header to decide where the user ends up.
## Consequences
- Redirect targets are explicit and greppable.- The layout reads one session key.- A form that needs to return where it came from does so with an explicit route and parameters.Layer three: rules
Specs and ADRs are documents I write for people, which the agent happens to read. Rules are the opposite. They exist so the agent behaves, and they are scoped to the paths they apply to.
Laravel Boost added project rules a couple of weeks ago. They shipped behind a flag in v2.4.12 and are on by default from v2.5.0. Rules are Markdown files in .ai/rules, they get committed to source control, and each one declares the globs it applies to in its frontmatter.
---paths: - app/Http/Controllers/**---
# Controllers
## Controllers are final, readonly and invokable
See ADR-0004. One `__invoke` method, named after the action. If a changeneeds a second method, it needs a second controller.
## Controllers do not validate
The first parameter of `__invoke` is always a Form Request, never`Illuminate\Http\Request`. If there is genuinely nothing to validate, therequest class still exists and returns an empty rules array.
## Controllers do not query
No `Model::query()`, no `where()`, no `first()`. Route model binding resolvesthe record, an Action or a Query object does everything else.
## Controllers return a redirect to a named route
Use `to_route()`. Never `back()`. See ADR-0006.Then one for requests:
---paths: - app/Http/Requests/**---
# Form requests
## Requests carry the validated payload out
Every request class exposes a `payload()` method returning a typed objectshaped for the Action that consumes it. Controllers do not call `validated()`.
## Authorisation lives in authorize()
Policies are called from `authorize()`, not from middleware in the controllerconstructor and not inside the Action.
## Messages are explicit
Every rule that can fail in a user-visible way has an entry in `messages()`.Default framework wording is not acceptable in this application.And routes:
---paths: - routes/**---
# Routes
## Routes are grouped by domain
Each domain has its own file in `routes/`, included from `web.php`. Middlewareis applied on the group, not repeated per route.
## Every route is named
Names are lower case and dot separated, scoped by domain and ending in theaction: `teams.invitations.store`, `invitations.accept`. `Route::resource` isnot used, see ADR-0004.
## Controllers are referenced by class-string
`StoreInvitationController::class`, not the array callable syntax and not astring reference to a method.Boost keeps an index.md next to those files mapping globs to rule files, and agents are instructed to check that index before planning or editing anything. The index is the entire mechanism, which is why the documentation tells you to record rules through the record-rule MCP tool rather than dropping files into the directory by hand. Boost regenerates the index as part of recording, and a rule file added manually sits there unread until something else regenerates it.
In practice I do not write these by hand either. I tell the agent what to remember and let it file the note with a glob, a title and the note text:
Remember that controllers never call validated() directly. The Form Requestexposes payload() and that is what the Action receives.If you are retrofitting this onto an application that has been running for years, v2.5.0 also shipped an infer-conventions skill. You ask the agent to use it, and it sweeps validation, controllers, authorisation, models, architecture, testing, frontend, database and console code, then does an open-ended pass for base classes, shared traits and module layouts. It records what your code does rather than what it should do, skips framework defaults and anything Pint or Rector already enforces, and reports genuinely mixed patterns back to you instead of writing them down as conventions. Everything it finds comes with its supporting evidence for you to approve, unless you tell it to “yolo” and record the lot.
That approval step is the useful part. Running it is the fastest way to find out how much of your convention actually exists in the codebase and how much of it only exists in your head.
Tweaking what Boost ships
Boost’s own guidelines are good, and they are doing a different job to mine. They cover framework-level behaviour and version-specific syntax, the things that stop an agent writing Laravel 9 code into a Laravel 13 application. The documentation is explicit about the split: guidelines and skills describe the Laravel ecosystem, project rules describe your application.
You do get two supported ways to change what Boost generates.
Adding your own is the first. Drop .md or .blade.php files into .ai/guidelines/ and they are included with Boost’s guidelines when you run boost:install.
.ai/guidelines/conventions.mdOverriding a built-in guideline is the second, and it works by matching the file path of the guideline you want to replace. The documentation’s example is creating .ai/guidelines/inertia-react/2/forms.blade.php to replace Boost’s Inertia React v2 form guidance. Match the path, and Boost uses your version instead of its own.
What I put in there is deliberately thin. Guidelines load upfront for every session regardless of what you are editing, which is the same context bloat problem as the five page AGENTS.md, so the only things I add at that level are the ones that genuinely apply to every file in the project: declare(strict_types=1) everywhere, the Pint preset, and a line telling the agent that this application’s conventions live in the rules index. Everything path-specific goes into .ai/rules where it loads at the moment of editing.
There is a nice practical consequence to this split. The generated files are disposable. Boost’s docs point out you can safely gitignore .mcp.json, CLAUDE.md, AGENTS.md and boost.json, because boost:install and boost:update regenerate them. What you commit is .ai/, which is the source those files are built from, and rules in particular are meant to be committed so the whole team and every agent pointed at the repository gets them. If a convention only exists in a generated file, it is one command away from being lost.
Two commands worth knowing. If Boost was already installed before you upgraded, run boost:update, because the instruction that sends agents to .ai/rules/index.md lives inside Boost’s guidelines and the agent will not know to look until they are regenerated. Add --discover when you want Boost to scan for newly installed packages and offer their guidelines and skills rather than only refreshing what you already have.
php artisan boost:update --discoverIf you ever need to switch rules off entirely, BOOST_RULES_ENABLED=false removes the record-rule tool and stops Boost managing the directory.
What this looks like when it runs
With the three layers in place the working loop is short. I point the agent at the spec, ask for a plan, and read the plan rather than the code. If the plan uses the routes from the spec table, references ADRs by number where a decision applies, and lists the files it intends to create, the code is almost always fine. If it is vague about responses, or hand-waves the request classes, the code will be wrong, and I would rather find that out in twenty lines of Markdown than four hundred lines of PHP.
The output for the invitation store path comes out like this, and it comes out like this every time:
Route::post( uri: '/teams/{team}/invitations', action: StoreInvitationController::class,)->name('teams.invitations.store');final readonly class StoreInvitationController{ public function __construct( private InviteTeamMember $action, ) {}
public function __invoke(StoreInvitationRequest $request, Team $team): RedirectResponse { $this->action->handle( team: $team, payload: $request->payload(), );
return to_route('teams.settings.show', $team) ->with('status', 'invitation-sent'); }}Nothing in that file is interesting, and that is the whole point of the exercise. The interesting decisions were made in the ADRs months earlier, written down once, and the agent is applying them rather than rediscovering them badly.
The gate that still matters
None of this removes review. It removes one specific kind of review, the sort where you spend forty minutes explaining a convention in pull request comments and then explain it again next week to the same agent in a fresh session. A rule is the durable version of that comment. Write it once, commit it, and every agent anyone on the team points at the codebase reads it before touching a matching file.
What I still review is judgement. Did it respect the boundary in the spec, or did it helpfully build the bulk invite feature I explicitly excluded? Did it handle the expired link, or write a happy path and a test that only exercises the happy path? Did the redirect go somewhere that makes sense for a user who just did that thing? Those questions need a person, and I have far more attention for them now that I am not litigating whether a controller should have two methods.
The uncomfortable part of all this is that barely any of it is AI work. Writing a clear spec, recording why you chose something, and being honest about what your codebase actually does are things we should have been doing anyway. Agents just made the cost of skipping them immediate and visible.
Keep Reading
Seven Days in Ten Milliseconds
A workflow that sleeps for three days is not a workflow you can test by waiting. Owning the clock, asserting on absence, and the races you only get one shot at.
Aug 2026 · 10 min read
LaravelBuilding an order fulfilment workflow in Laravel
Build a real order fulfilment workflow in Laravel: signals from webhooks, timeouts, retries, branching, sleep and saga compensation, one step at a time.
Jul 2026 · 19 min read
LaravelBuilding Bulletproof Laravel APIs using Schema-First Contract Validation
Stop letting undocumented fields into your Laravel API. Write the JSON Schema first, then enforce it in middleware, DTOs, and your Pest test suite.
Jul 2026 · 10 min read