Both sides passed their tests.
A whole class of bugs I've fixed lately had nothing wrong on either side. The frontend did what it meant to do. The API did what it was written to do. The bug was in the assumption one side made about the other.
These bugs are hard to find from one side. They're much easier when you own the feature from the database to the screen. Here are five from the last few months.
1. The field Laravel quietly dropped
Symptom. On the lesson whiteboard, a tutor stretches a note so the class can read it. On the next page turn, the next reload, and every student's screen, the note is back at the default size.
What each side believed. The tutor app sent the size in meta.font_size. The column was JSON and the service already whitelisted meta, so it looked like it should just work.
The gap. Laravel's $request->validated() returns only keys that have a validation rule. The only rule under meta was meta.color, so every other key under meta was dropped between the request and the model. There was no error, and the response looked fine.
Fix. meta.font_size got its own rule, bounded to sizes that are actually readable on the board. The regression test checks the value in both the response and the database row, so a future nested rule can't strip it silently again.
2. The PATCH that replaced the whole object
Symptom. Change a note's colour, and its size resets.
The gap. The colour toggle sent PATCH { meta: { color } }, assuming a merge. The API replaces meta wholesale. Colour was saved and size was erased.
Fix. The client sends the full meta on every update. The general lesson: for every JSON field, decide once whether updates merge or replace, and write it down where both sides will see it.
3. The ID sent to the wrong system
Symptom. Students who bought through the new checkout get a 500 when they download an invoice: Attempt to read property "data" on null.
The gap. The billing list returned invoices from two systems, each with a download descriptor: {type: "v2", invoice_id} or {type: "legacy", ...}. There was no V2 download endpoint yet, so the popup posted V2 invoice IDs to the legacy endpoint. The legacy endpoint looked for a legacy payment row, found none, and crashed. The student saw the raw PHP warning.
Fix. A new V2 download endpoint, scoped to the invoice's owner, returning the same base64 PDF format as the legacy one so the frontend's download code and messages are shared. The client routes on type, and a failed download no longer leaves the row's button disabled. I also added a guard in the legacy API that returns a clean 404 while the two deploys were out of sync.
4. The field the frontend never sent
Symptom. On the DUBTEL AI portal, AI agents created by a Tenant Admin show no client name.
The gap. The create and edit endpoints accepted business_client_id. The frontend never sent it. Agents were saved with NULL, and the update schema didn't even expose the field, so they couldn't be fixed afterwards.
Fix. The form sends it, the update schema accepts it, and the API checks that the submitted client belongs to the caller's tenant. That check was also missing, and the payload had been trusted.
5. The search that only searched one page
Symptom. Searching the phone-numbers list sometimes misses a number you know exists.
The gap. The API paginated. The frontend filtered search and status on the rows it had, which was only the current page. The page count still reflected the full unfiltered total, so results looked complete but weren't. The invoice summary cards (unpaid balance, overdue count) had the same problem.
Fix. Server-side search and status parameters on every list, and a dedicated /billing/summary endpoint for the totals.
What they have in common
Every one of these is a contract problem:
| Bug | The unwritten assumption |
|---|---|
| Dropped field | "Whitelisted means validated" |
| PATCH reset | "Updates merge" |
| Wrong endpoint | "Every invoice ID can go to the same endpoint" |
| Missing field | "The client sends everything the API accepts" |
| Paged search | "The rows I have are all the rows" |
What I do now when a feature crosses the API boundary:
- Test the round trip, not the response. Assert what's in the database row, not only what came back.
- Name the update semantics (merge or replace) for every JSON column.
- Type the variants. If a payload can come from two systems, make the type say so, so the compiler forces both branches.
- Filter where the data is. If the server paginates, the server filters.
- Never trust an ID from the payload without checking it belongs to the caller.
Owning the whole feature helps less because I know more, and more because I can't pretend the other side is someone else's problem.

Henry Iddirisu
AI product engineer · Accra, Ghana · Remote