# 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.

![Arif Hossen](https://arifhossen.dev/media/01a13b8a-5542-72a9-849d-040f01abf32c)

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.

```php
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.
