The repository pattern, eight codebases later: what it bought us on a day with fifteen tickets
Eight of our Laravel codebases carry a repository layer, seven of them on the same base class. On 12 October it let us shrink a thousand-line controller to 239 lines without changing one response body. This is the honest accounting, including where it does not hold.
On 12 October 2026 the board for the core uplift had fifteen tickets open and one of them was the largest: move users, roles and permissions out of their controllers. The user controller was 1,020 lines. At 14:04 a single commit took it to 239 lines, and at 14:25 the role and permission controllers followed. The user list, the role editor and the permission screens behaved as before. I want to explain why that was possible, because the reason is not the usual one given for the repository pattern.
The usual argument, and why it is not ours
The textbook case for a repository is that you can swap the database. We have never swapped one and do not plan to; every product here runs on PostgreSQL. The reason it paid off for us is smaller and more practical: it gave every piece of data access one address. When a controller needs to find a user by e-mail, there is one method that does it, with one decision about case sensitivity, one cache rule and one test. On the day we needed to move a thousand lines, that was the difference between a rewrite and a relocation.
Where the layer sits
The rule is written in the project's agent guide as a single line:
Route -> FormRequest -> Controller (thin) -> Service -> Repository -> Model never Model::create(), find() or raw SQL in a controller
| Layer | May know | Must not know |
|---|---|---|
| FormRequest | The shape of valid input and who may send it | How the data is stored |
| Controller | HTTP: status codes and response shape | Queries, caches, mail, business rules |
| Service | The rules: who may edit whom, what a role may grant | SQL, table names |
| Repository | Queries, eager loading, cache keys and their invalidation | The current user, HTTP |
| Model | Relations, casts, its own columns | Anything about requests |
Read that table as a list of where a change is allowed to land. A new rule about who can edit a user changes one service file. A slow list changes one repository file. Neither change can leak into the other, and neither shows up in a controller.
What the move looked like
This is the shape of a controller action after the move. It authorises, passes validated input to the service and returns:
public function store(StoreUserRequest $request)
{
$this->authorize('create', User::class);
return response()->json(
$this->withUserResource(
$this->users->store($request->validated(), auth()->user())
),
201
);
}
Behind it, the user stack grew by a service, a repository with its interface, two form requests, a policy, a resource and a small trait for the JSON helpers. The users commit touched 15 files: 1,566 lines added, 879 removed. The roles and permissions commit touched 23: 1,561 added, 739 removed. Net, the controllers shrank and the code that used to hide inside them became nameable.
The part that made it safe: change nothing a client can see
The one rule we set for the day was that response bodies would not change, including the awkward ones. Some actions answer with a non-standard {"error": ...} shape instead of the usual structure. A tidy refactor would have normalised them. We left them, and wrote in the controller's own comment that they were deliberately unchanged. The reasoning is plain: the interface, the tests and every screen that reads those shapes were already agreeing with each other, and a move is only a move if the outside stays still.
Two things kept it honest. First, tests were written or repaired before the structure moved: a user stack test with 23 cases, a role policy test and role and permission management tests. Second, a commit the same day repaired 28 old failures that had been sitting in the baseline, so a red result meant something again. At the end of the ticket the full suite was green at 550 passed.
What the layer found for us
When every write path to a user passes through one service, a missing rule is visible. We found one that afternoon: changing a user's e-mail address never reset the verified date. An administrator could change an address and the account stayed marked as verified for an address nobody had confirmed. It was fixed in the service, once, with a test, and the fix applies to every screen that edits a user.
How a tight day is managed, concretely
- One ticket, one day. The ticket was opened, started and closed on 12 October, and the status notes say what was done at each point rather than what was intended.
- Split, do not stretch. The ticket's original scope included the profile, log and two admin-utility controllers. They did not go in the same ticket; they moved to a follow-up, FP-188, and the tests for what did move stayed in. The record on the board says "Done in code" for users, roles and permissions and names the follow-up for the rest.
- A label for what is not yet proven. We use "Done in code" for work that is committed and tested but still needs a human run in a real environment. It stops a green suite from being read as a finished feature.
- Port, do not invent. The shape of the new services came from a sibling product that had already made the same move, and the commit says so. A known design makes a risky refactor a mechanical one.
The fuller story of that day, with all fifteen tickets, is in the S15 uplift post.
Which of our codebases still carry it
Eight of our Laravel codebases have a repository layer, and seven of them share one base repository class. They are not equally strict, and I would rather show that than claim uniformity. These are repository class counts, from a directory listing on 27 October 2026:
| Codebase | Repository classes | Note |
|---|---|---|
| The largest LUME application | 52 | The biggest layer by far |
| A second LUME application | 34 | About half its controllers reach a repository by the crude count below |
| An older sector toolkit | 22 | Shares the base class; more controllers still query directly than use a repository |
| An older admin system | 15 | Has its own repository classes, not the shared base; the habit is partial |
| The Net Advice website | 13 | Built on the Flightpack base |
| Sprintly | 13 | Built on the Flightpack base |
| The Flightpack core | 12 | After the strip to a bare core; 432 files went out |
| A trimmed LUME console | 10 | Adopted the Flightpack base in October |
A caution on how I read this. I also counted, crudely, how many controller files mention a repository and how many still contain a direct where or DB:: call. It is a prompt to look, not a score: a where on a collection counts as a miss, and a controller that calls a service counts as a miss for the repository. The two older codebases clearly lean the other way, with more controllers that query directly than controllers that use a repository. That is the next honest piece of work, and it is on our list rather than done.
What it costs
- A thousand-line base class. Our base repository carries caching, tagged invalidation, soft deletes, statistics and slow-query logging. It is useful and it is heavy. I would not recommend starting with it. Start with a base that does find, paginate and save, and add caching when a measured list needs it.
- Two files where there was one. Every repository has an interface. For a queryless lookup table that is ceremony. We accept it for the uniformity, and the interface also gives a test a seam.
- The temptation to query in the service. When a repository method does not exist yet, the quickest route is a
wherein the service. It is how the older codebases drifted. A reviewer has to say no, every time.
A later ticket shows the same benefit: bulk writes now clear the cache once, through a depth counter in the base class that defers the clear until the bulk operation ends. That rule has one home because the repository exists.
What I would tell another small team
For the wider structure around this layer, see the Flightpack architecture on one page.
Do not adopt a repository layer to swap your database; adopt it so that every query has one address. Write the layer rule in one line, enforce it in review, and the day you must move a thousand lines you will do it in two commits.