TL;DR
- Shipped account suspension across two Laravel apps — an enum, a middleware, a policy check (own post).
- Fixed a reverse-proxy gotcha: the proxy hid the gateway's real status behind NGINX's built-in 405.
- Moved a DB transaction to wrap only the business write, so a rollback stops erasing the audit trail.
- Added two agent (MCP) tools: read the source-of-truth record directly, and resolve a username to its owner.
Account suspension without deleting the user
Two apps needed the same thing: block a user from signing in without deleting the account. Same shape in both — an enum for the status, a middleware that rejects a suspended session mid-flight, and an admin toggle behind a policy. Its own post today, because the interesting part is the edge cases, not the toggle.
The NGINX 405 that lied
A reverse proxy sits in front of an API gateway. GET worked; anything non-GET came back as a flat 405 Method Not Allowed — but that 405 was NGINX's own, not the gateway's. The proxy was answering for a backend it should have forwarded to. The trap: a limit_except GET (or a stray error_page 405) makes NGINX intercept the method itself instead of passing it through.
| Before | After | |
|---|---|---|
| POST/PUT reaches gateway | No — NGINX returns its own 405 | Yes — forwarded |
| Status the client sees | NGINX built-in 405 | The gateway's real status |
| Debuggability | "why is everything 405?" | Real upstream errors surface |
Lesson: a proxy forwards; it shouldn't have opinions about methods. See a generic 405 on writes? Check the proxy before you blame the backend. I documented it in the config header so the next deploy doesn't reintroduce it.
Scope the transaction around the business write only
Subtle one. An operation did a business write and an audit write inside the same DB::transaction. Looks tidy. But when the business write failed and rolled back, the audit row rolled back with it — the one record meant to survive a failure vanished exactly when you needed it.
// before: audit dies with the thing it was supposed to witness
DB::transaction(function () {
$this->applyChange($record); // may throw
$this->audit->log('change', $record); // rolled back too
});
// after: transaction guards the business write; audit is logged around it
$this->audit->log('change.attempt', $record);
DB::transaction(fn () => $this->applyChange($record));
$this->audit->log('change.done', $record);
Rule of thumb: an audit trail records attempts and outcomes — so it can't live inside the transaction whose failure it's meant to explain.
Two read-only agent tools
Also added two MCP tools over an internal system: one reads the source-of-truth record directly, one resolves a username to its owner across every source it might live in. Both read-only. Treating MCP tools as narrow, read-only lenses — rather than write endpoints — keeps them safe to hand an agent and easy to test.
That's the day: one security feature, one infra gotcha, one durability fix, two agent lenses.
Top comments (0)