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
LayerMay knowMust not know
FormRequestThe shape of valid input and who may send itHow the data is stored
ControllerHTTP: status codes and response shapeQueries, caches, mail, business rules
ServiceThe rules: who may edit whom, what a role may grantSQL, table names
RepositoryQueries, eager loading, cache keys and their invalidationThe current user, HTTP
ModelRelations, casts, its own columnsAnything 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

  1. 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.
  2. 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.
  3. 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.
  4. 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:

CodebaseRepository classesNote
The largest LUME application52The biggest layer by far
A second LUME application34About half its controllers reach a repository by the crude count below
An older sector toolkit22Shares the base class; more controllers still query directly than use a repository
An older admin system15Has its own repository classes, not the shared base; the habit is partial
The Net Advice website13Built on the Flightpack base
Sprintly13Built on the Flightpack base
The Flightpack core12After the strip to a bare core; 432 files went out
A trimmed LUME console10Adopted 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 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.

Retrospective post. Written on 27 October 2026 from our project records and dated to the day the work happened.
Net Advice
Loading