If your numbers drift and nobody can explain why, check whether your business logic is living inside database triggers. A trigger is code that runs on the database host, in database syntax, with no application log and no stack trace. When it breaks, you do not find out from your monitoring. You find out from a customer asking why a counter is wrong.
I spent about a month moving one live SaaS product off its database triggers, one domain at a time, with no downtime and no big-bang rewrite. Ten triggers retired between 22 April and 17 May. Ten still running, on purpose. Here is how I decided what to move, what to leave, and why the order mattered more than the speed.
What a trigger actually costs you
A database trigger looks like a free win when you write it. Something has to update a counter every time a row is inserted, and putting that rule in the database means it can never be skipped. Every code path gets it automatically. No developer can forget to call it.
That is the pitch. Here is the bill.
The logic is invisible. It does not appear in your codebase when you search for the counter’s name. A developer reading the application code sees a plain insert and reasonably concludes that an insert is all that happens. The counter update is real, it affects customers, and it is nowhere a normal reader would look.
There are no logs. When application code fails you get a stack trace, a request ID, and a line number. When a trigger misfires you get a number that is quietly wrong. Nobody gets paged, because nothing threw.
It cannot be tested properly. Triggers live in the schema, not in the code, so they sit outside the test suite. You cannot easily assert on them and you cannot mock them.
It locks you to one database. Trigger syntax is vendor specific. The moment you consider moving to a managed database service, or a different engine, every trigger becomes a rewrite. Logic in triggers is a portability tax you pay later, at the worst possible moment.
And the interest compounds. The number of triggers only ever grows, because adding one is a five-line change and removing one requires understanding everything that depends on it.
None of this is exotic. This is what a product accumulates when it has been earning money for years and shipping features under deadline. The triggers were the right call for the person who wrote them, with the information they had. They just stopped being the right call once the product got big enough that nobody could hold it all in their head.
The rule I applied
Business logic belongs in the application, where it can be read, logged, tested, and traced.
That is the whole principle. Everything below is the boring, careful work of applying it to a system that real customers were using the entire time.
Why I did not do it in one release
The tempting version of this project is a single pull request that deletes every trigger and adds every replacement. It is tempting because it is conceptually clean, and it is exactly how this kind of work goes wrong. This is the same argument I make about modernizing legacy PHP without a rewrite, just one layer down. The database is where it is hardest to follow, because a bad migration there is the one you cannot quietly revert.
A big-bang cut has one moment where everything changes. If a counter starts drifting three days later, your suspect list is every trigger you touched and every listener you added. You have no way to narrow it, so you either roll the whole thing back or debug it under pressure with customers watching.
So I did it domain by domain, riskiest understanding first. Each domain shipped as its own migration with its own tests. If something drifted, the suspect list was one domain, and the rollback was one migration.
The shape of it, with the domains kept generic:
- 22 April, the first domain. Four triggers in one day, because they all touched the same table and moving one without the others would have left the counters inconsistent between releases.
- 30 April, a single counter, on its own.
- 2 May, two more counters, related enough to move together.
- 4 May, a set of three covering create, update, and delete on one derived value. A trigger set like this has to move as a unit or the value goes wrong on whichever path you leave behind.
- 17 May, an orphan. A trigger with no matching code path left at all, firing on a rule the product had stopped using.
That is ten triggers over about four weeks. Nothing dramatic happened on any of those days, which is the point.
Two things in that list are worth more than the schedule. The first is that the groupings are not arbitrary: triggers that touch the same table, or the same derived value, move together or not at all. Splitting them across releases means running a window where half the rule is in the database and half is in the code, which is strictly worse than either.
The second is the orphan. Almost every codebase this age has at least one. It costs nothing to run and nothing to remove, and it is the clearest evidence of the real problem, which is that nobody had a list. It was firing for years after the feature that needed it went away, and the only reason anyone knew is that I went looking.
The replacement pattern
Each retired trigger became an application event with listeners attached.
Take a delete, which is the most instructive case. The application now fires an event carrying the in-memory row, and the listeners read what they need from it. That detail matters more than it looks: the event fires before the row is removed, so listeners can still read the values they need instead of querying a row that is about to disappear. Getting that ordering wrong is the obvious way to reimplement a trigger badly, and it is a mistake the trigger version could not make, because a trigger runs inside the statement.
The listeners split into two tiers, and choosing the tier for each one is the actual engineering judgment in this project.
Tier one runs synchronously, inside the same transaction as the write. This is for anything where a wrong number is a correctness bug. Counters that gate what a customer is allowed to do go here. Running them in the same transaction reproduces the one genuinely good property a trigger had: the counter and the row it counts can never disagree, because they commit or fail together. If I had moved these to a background queue I would have traded invisible-but-correct for visible-but-eventually-correct, and for a quota that gates what a customer is allowed to do, that is a downgrade.
Tier two runs asynchronously through a queue. This is for denormalized statistics, third-party syncs, and webhooks. Nothing here needs to be true at the instant of the write. It needs to be true soon, and it must not make the customer wait. Moving these off the request made signup measurably quicker, because the user’s request stopped waiting on work they did not care about.
The queue is a set of message-queue services in the application, not a stock framework queue. This product runs on Illuminate components rather than the full framework, so the queue layer is one we control directly.
The part most teams skip
Moving logic out of triggers makes it visible in the code. It does not automatically make it visible in production. An async listener running in a worker is a different kind of invisible: it is real code with real logs, but its logs are disconnected from the request that caused it.
So every event carries a trace ID, stamped from the request context when the event is created, and the queue payload carries it across the worker boundary. When a counter looks wrong, I can take the original admin request and follow it all the way through to the background job that touched the number, across process boundaries.
Without that, you have not removed the invisibility. You have moved it.
This is the step I would push back hardest on if a client wanted to cut scope. The trigger migration is worth much less if the replacement is only marginally easier to debug.
What I deliberately left alone
Ten triggers are still running. That is not unfinished work. That is the plan.
The ones I retired had something in common: they were counters and statistics, the logic was understood, and the code paths that fired them were all reachable from the application. Well-bounded problems.
The ones still running touch areas where I do not yet have the same confidence about every code path that writes to those tables. Some of that data is written by paths outside the normal application flow. Retiring a trigger before you can enumerate every writer is how you silently lose data integrity, and silent is the worst kind.
So they stay, and they get retired domain by domain in the same risk-first order, when each one is properly understood. A trigger I understand and chose to keep is a completely different thing from a trigger nobody has looked at. The first is a decision. The second is a liability.
The honest version of this work is not “we eliminated all our technical debt.” It is “we converted the part we understood, we wrote down why the rest is still there, and we stopped the pile from growing.”
The cut
One rule I hold to on every migration like this: there has to be a moment where the old code is formally deleted.
Both the old and new paths must work simultaneously while you migrate, because that overlap is your safety net. But an overlap that never ends is not a migration. It is two systems doing the same job, and now every future developer has to know about both. That is worse than where you started.
So each domain here ends with a migration that drops the trigger. Not disables it. Drops it. The old path is gone and the code is the only source of truth for that behaviour.
If your modernization plan does not name the moment the old thing dies, it is not a plan yet.
What this actually bought
The counters for migrated domains stopped being a mystery. When a number is wrong now, I can find out why by reading code and following a trace ID, instead of guessing.
Signup got faster, because the side effects that used to run inline now run on the queue.
The migrated domains have integration tests, which was impossible while the logic lived in the schema.
And the database moved closer to being just a database. Every trigger removed is one less thing that has to be rewritten if this product ever changes where it is hosted.
If you think you have this problem
You almost certainly do if your product is more than about five years old and has ever had a deadline.
Start here. Ask your developer to list every trigger in your production database. If the answer takes more than a few minutes to produce, or comes back with “I did not know we had those”, that is your answer. The number itself matters less than whether anyone knows it.
Then ask which of them anybody has read in the last year. Triggers nobody has read are not doing a job you chose. They are doing a job somebody chose years ago, under conditions that have probably changed.
You do not need a rewrite. You need to make the invisible visible, one domain at a time, in an order that means a mistake costs you one migration instead of your weekend.
The bill for that is smaller than the bill for the alternative, and it arrives on your schedule instead of during an incident. That is the whole economics of what modernizing legacy PHP costs: the work is cheap while it is optional and expensive once it is not.
Questions founders ask me about this
Are database triggers always bad? No. A trigger enforcing a data integrity constraint, like keeping a timestamp accurate, is reasonable. The problem is business logic in triggers: rules about what your product does, hidden where no developer will read them and no log will report them. The test is whether a new developer reading your application code would be surprised by what happens on a write.
How do I know if my product has this problem? Ask your developer to list every trigger in your production database and say which ones they have read in the last year. If producing the list is hard, or the answer is that nobody has looked, the logic in there is running unsupervised. That is the actual risk, not the count.
Will removing triggers break my live product? It will if you do it in one release. It does not need to be one release. Move one domain at a time, keep both paths working during the overlap, ship each domain with its own tests, and drop each trigger in its own migration. Then any problem is traceable to one small change and reversible on its own.
Is this worth paying for if nothing is currently broken? Nothing is currently broken is exactly when this work is cheap. The same migration done in a hurry, after a counter has drifted and customers have noticed, costs far more and gets done under pressure. You are buying the ability to change your product later without being scared of it.
Should every trigger come out? No, and be suspicious of anyone who says otherwise. I retired ten and deliberately left ten, because I could enumerate every code path for the first group and not yet for the second. A trigger you understand and chose to keep is a decision. A trigger nobody has read is a liability. Moving things from the second category to the first is the real work.