Middleware is where I put the boring guarantees
Caching, rate limiting, security checks, and timing were scattered across an insurance ERP one controller at a time. Pulling them into a middleware stack did not make anything faster on its own. It made the promises an endpoint keeps possible to read.

Published 2026-10-14 by Arif Hossen
Tags: Laravel
Source: https://arifhossen.dev/blog/middleware-is-where-i-put-the-boring-guarantees
Where it started
The first version of the API had no middleware to speak of. Every controller did its own thing: one checked a header, one cached a query for five minutes because a page felt slow, one logged a timing line somebody added while debugging and never removed. Nothing was wrong exactly. It was just that no two endpoints promised the same thing, and nobody could say which ones did what without reading them.
That is a fine state for a prototype and a bad state for a system three developers and two interns maintain while stakeholders ask for new modules every week.
Four guarantees an endpoint opts into
The stack
I wrote four pieces of middleware and one group that composes them. An endpoint joins the group and inherits all four; an endpoint that needs to opt out of one says so explicitly, in the route file, where a reviewer will see it.
Route::middleware([
'api.security', // header and payload checks
'api.throttle', // per-client rate limiting
'api.cache', // response caching, invalidated on write
'api.monitor', // timing and slow-query capture
])->group(function () {
Route::apiResource('payroll/runs', PayrollRunController::class);
});
routes/api.php
Illustrative shape, not a verbatim copy of client code.
The list is a sequence, not a set
Order matters
Every mistake I made in the first week was an ordering mistake. Caching above rate limiting means a cached response never counts against a client's budget, so a scraper gets an unlimited free tier. Monitoring below caching means the timing you record is the time to read from Redis, which flatters every number you have.
| Layer |
Guarantee |
Why it sits here |
| security |
Rejects malformed or unsigned requests |
Nothing downstream should spend work on a request that will not be served |
| throttle |
Counts every request against a client budget |
Above the cache, so a cached hit still costs the caller something |
| cache |
Serves a stored response, cleared on write |
Below throttling, above the controller, so the work is genuinely skipped |
| monitor |
Records duration and slow queries |
Outermost timing wrapper, so the number is what the client actually waited |
A cache nobody can invalidate is a bug with a schedule
Invalidation
The ad hoc caching I replaced was all time-based. Five minutes, ten minutes, an hour for the report nobody wanted to wait on. In a finance module that is not a performance decision, it is a correctness decision: an accountant posts a journal entry, refreshes, and sees the old balance. They do not conclude that the cache is stale. They conclude the posting failed, and post it again.
[!CAUTION]
Cache keys are tagged by the resource they read. A write to that resource clears the tag. Nothing expires on a timer that a human is waiting on.
Time-based expiry still exists, but only as a backstop measured in hours, for the case where an invalidation path is missed. The primary mechanism is the write itself.
Slow endpoints stopped being anecdotes
The payoff
Before the monitor layer, performance discussion sounded like this: someone says the payroll list feels slow, someone else says it is fine on their machine, and the conversation ends. After it, every request wrote a timing record and every query above a threshold wrote its own line. The argument became a query you could run.
Two of the endpoints people complained about were fine. The ones actually costing seconds were three nobody had mentioned, all of them reports, all of them loading a relation inside a loop. I would not have found them by guessing, and I did guess first.
- 4 Middleware layers replacing per-controller checks
- 1 Place to change what an endpoint promises
- 3 Slow reports found that nobody had reported
Counts describe work I can account for. The product is client-owned, so no latency or business numbers are published here.
Build it first, or pay to retrofit it
What it cost
I built this in the middle of the project, and every module written before it had to be revisited to sit behind it. Controllers had their own header checks to delete, their own cache calls to unwind, and a couple of them depended on being able to write a response the cache layer now owned.
None of that was hard. All of it was avoidable. On the next system I will write the boring guarantees before the first interesting feature, because the interesting feature is what makes them expensive to add.