DEV Community

Cover image for Usipoziba Ufa, Utajenga Ukuta — On Technical Debt and the Discipline to Fix It
cynthia
cynthia

Posted on

Usipoziba Ufa, Utajenga Ukuta — On Technical Debt and the Discipline to Fix It

There's a Swahili proverb that lives in my head rent-free as a developer:

Usipoziba ufa, utajenga ukuta.
If you don't seal the crack, you'll rebuild the wall.

I was recently doing a technical review of a school management system — a real, live, production codebase built by a team moving fast and shipping features. The kind of project where prototyping decisions become permanent ones, and technical debt quietly accumulates in the walls.

I could have done the minimum. Fixed what was on the ticket, shipped, moved on. Instead I did what I always do — I kept pulling the thread.

Here's what I found, and the first principles behind every fix.


Context (Because You Deserve It)

This was a PHP/Laravel backend — a language I was relatively new to at the time. Laravel is a popular PHP web framework, and Tinker is its interactive console, like a REPL you can use to query and manipulate the database directly without building a UI. Think of it as a surgical tool for backend diagnosis.

The system managed multiple schools on one platform (multi-tenancy), had just added a sales agent referral program, and needed a security and UX audit before the next release.

I wasn't here to judge the original code. Prototyping leaves debt — that's not incompetence, that's software. My job was to leave it better than I found it.

Screenshots and PR references: [link to PRs here when publishing]

Closed PR's CynthiaWahome

Opened PR's CynthiaWahome


1. Seal the Crack Before You Need a New Wall

The most critical find: a single helper method with a dangerous fallback.

The system was supposed to identify which sales agent was making a request by checking their login session. Correct. But if that check failed — instead of stopping — it fell back to reading an agent_id value straight from the URL.

Anyone. Any authenticated user. Could type ?agent_id=someone-elses-id in the URL and gain full access to that agent's dashboard, commissions, and payout history.

The fix was one line. But the lesson is about what happens when you don't fix it: a crack becomes a wall you eventually have to rebuild entirely — after the breach.

The principle: User input is untrusted. Always. URL parameters, form fields, headers, cookies — all of it can be faked. Never use client-supplied data to make an authorisation decision. This is not a PHP principle. This is not a Laravel principle. It is a law of the internet.


2. Never Break the Architecture to Solve One Edge Case

This was the most architectural moment of the whole review.

The system needed a Global Super Admin — someone who could see all agents across all schools. Problem: every user in this system is physically tied to a specific school in the database. That's the security contract. And it's enforced at the database level, not just in code.

The tempting fix: make the school_id column nullable. One migration, problem solved.

I almost did it. Then I stopped.

Making that column nullable wouldn't just solve this one case — it would quietly weaken the security guarantee for every school on the platform. The whole point of the design was that cross-school data leaks are structurally impossible. One nullable exception and that guarantee has a crack in it.

The actual solution: create a real management entity — "Platform HQ" — and make the Super Admin belong to it. The architecture stays intact. The admin has a valid home in the system. No nullable columns. No broken contracts.

Own the strongest room inside the walls. Don't remove the walls.

This transcends PHP, Laravel, any framework. Whenever you're about to modify a database schema to accommodate a single edge case — pause. Ask whether the edge case can be made to fit the existing contract instead.


3. Guard the Exit, Not the Entrance

A product decision dressed as a security question: should agents who sign up via Google OAuth be blocked from the dashboard until an admin manually approves them?

The security case for blocking: obvious. Unverified users shouldn't see sensitive data.

The product case against: onboarding friction at the highest-intent moment is how you lose users. Someone who just signed up via Google and immediately hits a wall will leave. You've completed their registration and immediately punished them for it.

The solution: guard the exit, not the entrance.

Let them in. Let them see the dashboard. Let them understand the product and start generating referrals. But the payout service — where money actually moves — requires approval before it runs.

This is applicable everywhere a payment or financial feature sits behind a registration flow. Google OAuth (or any SSO) is a legitimate, convenient way to onboard users. The mistake is assuming that authentication and authorisation are the same thing. They're not. Authentication says who you are. Authorisation says what you're allowed to do.

Lock the vault. Leave the lobby open.


4. The Bug That Only Existed in Production

A "Ghost Object" bug — one of the more disorienting things you'll encounter.

The dashboard "Approve Agent" button returned an error saying the agent wasn't in the right status. I checked directly in Tinker — the status was correct. So why was the controller seeing null?

Diagnosis via logs showed the entire agent object was empty. The server received the ID from the URL but never fetched the actual record.

Root cause: the admin routes were wired up manually in a way that bypassed the standard API middleware. Route Model Binding — the feature that automatically resolves URL parameters into database records — only runs inside that middleware. Without it, the server got an ID and did nothing with it.

The principle: "Works in local tooling" and "works in the API" are not the same test. Your REPL, your console, your local scripts — they run in a different context than your actual request lifecycle. When something works in one and fails in the other, look at what the request pipeline does that your tooling skips.


5. Harden Your Logic Against Its Environment

This one is underrated. We added trim() and strtolower() to status comparisons in the data model — not because users were submitting messy input, but because different database engines handle string comparison differently.

Postgres is strict. MariaDB can be inconsistent about trailing whitespace and case sensitivity depending on configuration. An internal state machine that depends on string equality needs to be immune to the floor beneath it.

The principle: Write code that's correct regardless of the environment it runs in. Defensive logic isn't paranoia. It's acknowledging that you don't control every variable between your code and the database driver.


What I Actually Came For

I came in to add some UI banners. I left having:

  • Closed a privilege escalation hole
  • Preserved a multi-tenancy security contract that almost got weakened
  • Fixed a ghost object caused by misconfigured middleware
  • Moved an auth check from the wrong door to the right door
  • Hardened string comparisons against database inconsistency
  • Opened issues, flagged technical debt, and added milestones so none of it gets lost

That last point matters. I could have fixed my ticket and left. But I logged everything else I found — as issues, as notes, as future work. Because debt you've seen and documented is manageable. Debt you've seen and ignored is a wall waiting to be rebuilt.


I'm naturally curious. I'm not afraid of bugs — they're how I learn. And I genuinely enjoy taking a codebase that's been moving fast and making it move more safely.

Cyfamod Technologies · GitHub

Cyfamod Technologies has 5 repositories available. Follow their code on GitHub.

favicon github.com

The language was PHP. The principles aren't.

Usipoziba ufa, utajenga ukuta.

Seal the crack now. Or rebuild the wall later.


Backend engineer. I write about systems, architecture, and what I learn by poking at things. Portfolio: cycy.is-a.dev 🚀

Top comments (0)