The app worked. Users could log in. Data was saving. Nobody was on fire.
So naturally, I went looking for the fire.
I was asked to audit a new Agent Referral feature on a school management system — agents refer schools, earn commissions, the usual. No brief. No checklist. Just "make sure it's fine."
It was not fine. Nothing was visibly broken, but the architecture had been quietly making peace with some genuinely bad decisions. Everything held together by vibes and accumulated runtime state.
Here's what I found — and why it matters whether you're writing PHP, Python, Go, or whatever language you've convinced yourself won't betray you.
The Back Door That Was Literally Labelled
There was a helper method — resolveAgent() — whose job was to figure out which agent was logged in. Two steps:
- Check if the authenticated user is an Agent ✅
- If not — check if the URL has an
agent_idparameter, and if so, load that agent from the database ❌
Step 2 is called Privilege Escalation. Any authenticated user — a school admin, a teacher, literally anyone — could type ?agent_id=some-uuid into the URL and gain full access to that agent's dashboard, commissions, and payout history.
The fix was one line:
return $user instanceof Agent ? $user : null;
You're an agent or you get nothing. No fallbacks. No trusting what someone typed in a URL.
The principle: Never trust the client. Anything arriving from a browser — URL params, form fields, headers, cookies — can be faked. Assume it is. This isn't a PHP rule or a Laravel rule. It's a law of the internet, as immovable as gravity and significantly less forgiving.
The Database That Only Looked Stable
To prove my fix worked, I wrote tests. Ran them. The whole suite detonated before a single test executed — the database couldn't rebuild itself from the migration files.
Two issues:
Issue 1: A migration tried to set a default value on a TEXT column. TEXT is unbounded — think of it as an essay answer sheet with no word limit. The database's response was essentially "I'm not pre-filling every essay box. That's expensive and I refuse." MariaDB won't allow it. MySQL might let it slide depending on config. They're like twins — 99% identical, except MariaDB read the rulebook.
Issue 2: A composite primary key included a nullable column. A primary key is the database's ID badge for a row. An ID badge that might be blank can't identify anything. The database said no. Correctly.
The wild part? The production app was running fine. The database had been built incrementally over months, never torn down. My tests used RefreshDatabase — which destroys everything and rebuilds from scratch every run. That clean slate exposed every shortcut that had been quietly accumulating.
It's like a building that looks solid because nobody's pulled up the floorboards in years.
The principle: Your code isn't portable until it survives a clean build. If your app only works because the database was assembled "just right" and nobody's had to start fresh — that's not stability, that's a patient time bomb.
The Vault and the Clerk
Once the database rejected the defaults, I had to put them somewhere else. Which raised the interesting question: where do defaults belong?
Think of your backend as a bank:
- The vault (the database): stores things safely. Doesn't think. Doesn't decide.
- The clerk (the application): handles logic. Decides what to fill in when nobody specified.
The original code was asking the vault to think. The vault refused.
I moved the defaults to the Model layer — the part of the code that defines what a "Term Summary" actually is:
protected $attributes = [
'overall_comment' => 'This student is good.',
'principal_comment' => 'This student is hardworking.',
];
Now the application handles the logic. The database just stores. Swap Postgres for MySQL tomorrow — the defaults don't care. Rewrite the frontend entirely — the backend doesn't notice.
The principle: Separation of Concerns. Each layer minds its own business. The systems that survive change are the ones where nobody's doing someone else's job.
Tests Are Receipts, Not Optional
I wrote eight tests. One Factory to generate realistic fake data. Then:
-
5 security tests: a school admin tries every angle to access an agent's dashboard. Every single one returns
401 Unauthorized. - 3 auth tests: agents can still register, log in, get rejected with the wrong password. The front door still works.
"I fixed it" is a belief. Eight green checkmarks are evidence.
Six months from now when someone asks "are we certain a school admin can't get into agent data?" — the answer isn't a Slack message. It's a test suite that runs every time someone touches that code.
Tests are documentation that doesn't go stale and can't be misremembered.
The Part Worth Keeping
Two things kept surfacing and they're worth separating cleanly:
First Principles are the physics — the laws that don't change regardless of framework or language:
- User input is untrusted. Always.
- A primary key that might not exist can't identify anything.
- A database won't efficiently enforce constraints it wasn't built for.
Design Patterns are the blueprints that implement those laws:
- Zero trust → authentication guards, no URL parameter fallbacks
- Defaults the database won't hold → move them to the Model
- Prove it works → Factories and tests
The pattern is the tool. The principle is why you pick it up.
Three questions worth asking about every system you touch:
- Who owns this responsibility? If the answer isn't obvious, that's the bug.
- What is actually, fundamentally true here? Strip the assumptions.
- Can you prove it? If not, you don't know — you just haven't been wrong yet.
The recipes change. The physics don't.
Drop a comment — genuinely curious: what's the worst "it worked in production" moment you've walked into? The more specific the horror, the better. 👇
Backend engineer. I write about systems, architecture, and what happens when you poke at things that were better left unexamined. Portfolio: cycy.is-a.dev 🚀
Top comments (0)