[{"data":1,"prerenderedAt":516},["ShallowReactive",2],{"blog-posts":3},[4,45,77,110,140,172,202,233,257,280,308,333,357,385,409,434,466,491],{"slug":5,"title":6,"description":7,"date":8,"updated":9,"tags":10,"pillar":16,"draft":17,"image":18,"imageAlt":19,"markdown":20,"faq":21,"readingTimeText":43,"readingTimeMinutes":44},"cognitive-debt-is-cheap-until-the-code-takes-money","Cognitive debt is cheap until the code takes money","Cognitive debt costs you a slow refactor in most code and a wrong payment in billing code. How to scale AI review to what breaks when you are wrong.","2026-08-04T00:00:00.000Z",null,[11,12,13,14,15],"ai","code-review","code-quality","billing","maintainability","products-that-last",false,"/blog-covers/products-that-last.png","Products that last cover","\n## TL;DR\n\nCognitive debt does not charge the same interest everywhere in a codebase. Scale how hard you look at generated code to what breaks when you are wrong, because in billing code the bill arrives as a wrong payment rather than a slow refactor.\n\nThis post is mostly an argument with one part of somebody else's work, so let me say up front whose. Addy Osmani has written the clearest account of how AI erodes understanding, across [cognitive surrender](https://addyosmani.com/blog/cognitive-surrender/), [don't outsource the learning](https://addyosmani.com/blog/dont-outsource-learning/) and [intent debt](https://addyosmani.com/blog/intent-debt/). The distinction he takes from Steven Shaw and Gideon Nave's Wharton paper is the one worth carrying: offloading is handing over the work and keeping the judgement, surrender is when you stop forming your own view at all, so there is nothing left to check the output against.\n\nI think the diagnosis is right. What I want to push on is the implied prescription, which is to think harder in general. In the part of the codebase I work in, general is the wrong unit.\n\n## Two kinds of debt, two different interest rates\n\nTake an admin screen. You let an agent build it, you skim the diff, you ship. That is debt. The interest arrives months later as a change that takes a day instead of an hour, because you have to work out what the code does before you can touch it. Annoying, recoverable, and nobody outside the team hears about it.\n\nNow the same posture on a renewal handler. The interest is not charged in months and it is not charged in your time. It is charged the first time a webhook is redelivered, and it is charged to a customer.\n\nThree things make that gap wider than it looks.\n\n**Revenue failures are quiet by construction.** A broken page throws something a human sees. A renewal that fails to extend an expiry throws nothing, because from the software's point of view the operation succeeded. I went through the specific shapes in [six payments edge cases that bite at 2am](/blog/six-payments-edge-cases-that-bite-at-2am); most of them share the property that the system reports success while getting it wrong. Osmani's line is that surface correctness is not systemic correctness. In billing code the surface is worse than merely unhelpful, because it is built to look calm.\n\n**The cost lands on someone else first.** Debt in ordinary code costs you a bad afternoon. Debt in billing code costs a customer, and then costs you the conversation about it.\n\n**Reconstruction is hardest exactly where it matters most.** If you do not understand a UI component you can read it and work it out. If you do not understand why a refund path checks a flag before issuing a credit, reading tells you what it does and nothing about what it was defending against. That reason lived in an incident. If nobody wrote it down it is gone, and no amount of reading brings it back.\n\nThat last one has a name and a literature. Margaret-Anne Storey's [triple debt model](https://arxiv.org/abs/2603.22106) separates technical debt, which lives in the code, from cognitive debt, which she frames as a team-level erosion of *shared* understanding, from intent debt, which lives in artefacts nobody wrote: the goals, constraints and rationale behind why a system is the way it is. Osmani's [essay on intent debt](https://addyosmani.com/blog/intent-debt/) is where it clicked for me, and his point is the one that bites here. An agent can restore comprehension by reading the code back to you. It cannot restore intent, because asked why a guard clause exists it will produce a confident answer whether or not it has any idea.\n\n## Does AI-assisted coding make engineers worse?\n\nOn unfamiliar material the evidence points that way, and I want to be careful about how far it goes.\n\nAnthropic ran a [randomised trial](https://www.anthropic.com/research/AI-assistance-coding-skills) where engineers learned an unfamiliar Python library, half with an assistant and half without. The assisted group scored around 50% on a comprehension quiz against roughly 67% for the group working by hand, with the widest gap on debugging questions. Anthropic describes that as close to two letter grades. The speed advantage was small enough not to be statistically significant, so on this task the assistance bought almost no time and cost most of the understanding.\n\nThe part I keep returning to is smaller and much less certain. Inside the assisted group, the people who came out well were the ones who used the model to build comprehension: some asked only conceptual questions and never had code generated for them, others generated code and then asked for an explanation of what they had just been handed. The worst scores went to people who generated and moved on. The authors are explicit that this is a qualitative observation across a handful of participants and not a causal finding, and the groups involved are single digits. I would not build a policy on it.\n\nBut it is the only evidence I have seen pointing at *how you asked* instead of *whether you used it*, and it matches something I notice in myself. That is not evidence. It is the reason I bothered to read the study.\n\n## Where the posture slips, in money code\n\nOsmani names four moments where surrender happens: reading the diff, debugging something you do not understand, making a design call, and learning something new. I have not found a fifth. What I can add is what three of them cost when the directory is `app/Billing` instead of anywhere else.\n\n**Reading a diff and calling it a review.** His version: the tests pass, the names look reasonable, you approve, and what you did was ratify instead of review. The billing-specific question that separates the two is narrow enough to ask every time. *What does this do if it runs twice?* With at-least-once delivery the second run is normal operation, not an error case, so a handler that is only correct once is a handler that is wrong and has not been caught yet.\n\n**Fixing something you cannot reproduce.** His version: paste the trace, apply the fix, move on, and your mental model is now wrong somewhere you cannot point to. Here the cost compounds differently. A wrong model of a rendering path costs you one bug. A wrong model of a state transition costs you every bug that transition will ever produce, because the misunderstanding is still sitting there generating them.\n\n**Taking the framing along with the answer.** His version: you ask queue or direct call, and accepting the answer means accepting the model's version of the question. The money-code equivalent is whether to store an amount as an integer or trust a library to handle it. I have a position on that, [integer cents, never a float](/blog/store-money-as-integer-cents-never-a-float), and the reason it is a position instead of a preference is that this is a class of decision you cannot recover from by editing later.\n\nNone of that is a new taxonomy. It is his taxonomy with the interest rate recalculated.\n\n## The same failure, seen from the other chair\n\nThere is an inverted version that gets discussed less, and it applies to anyone paid to be the expert on someone else's system.\n\nAntonio Pagano wrote about [the senior engineer's trap](https://wawand.co/blog/posts/the-senior-engineers-trap/) for Wawandco: an architecture review where the facilitator kept finishing the other engineer's sentences, adding boxes to the diagram, steering toward the answer he had already reached. The session found the real risks. The other engineer left compliant instead of sharper.\n\nPut that beside the surrender research and it runs on a similar mechanism with the roles swapped. In one, a model hands you a complete answer and you stop building your own. In the other, you are the one handing over the complete answer, and someone else stops building theirs. Pagano frames it through scaffolding theory instead of borrowed confidence, so the parallel is loose. The outcome is not.\n\nThe team-level version has been studied. Alami, Zahedi and Krancher's [*Antecedents of psychological safety in agile software development teams*](https://doi.org/10.1016/j.infsof.2023.107267) (Information and Software Technology, 2023) ran eighteen interviews and then surveyed 365 practitioners to find out what produces the conditions where people say the awkward thing. The answers were behavioural: openness, absence of blame, collective decision-making inside the team, and leadership taking ownership of the climate.\n\nThe result I did not expect is a negative one. Team autonomy, slack time and technical practices providing a safety net were not found to promote psychological safety. That is a non-significant result on a survey rather than proof of no effect, so read it as an absence of evidence. But it is an uncomfortable absence, because the safety net is the thing most teams reach for first, and because Pagano's own post cites a separate study finding that autonomy does increase psychological safety. The literature is not settled and I am not going to pretend otherwise from an adjacent field.\n\nOne caveat on my use of it. That paper studies teams, and the thing I am describing is often one person deciding whether to admit to themselves that they have not formed a view. There is no interpersonal risk in that, so it is not psychological safety in the measured sense. I am borrowing the shape of a finding, not its authority.\n\nWhat I take from all three is narrower than any of them: the conditions under which somebody admits they do not understand something are made by behaviour, and tooling does not appear to be sufficient. Which is inconvenient, because tooling is what everyone would prefer the answer to be. Myself included, since a checklist is a thing I can ship and a climate is not.\n\n## How to review AI-generated billing code\n\nThe habits below are Osmani's, from the posts linked at the top. I am reproducing them because they work, not because I improved them. One is mine and I have marked it.\n\n1. **Write down what you expect before you read the answer.** Two lines: what you think is wrong, and roughly what the fix should look like. If the answer matches, you are calibrated. If it does not, you have a decision to make instead of a plausible paragraph to accept. This is the one I skip most often when tired, which is exactly when it matters.\n\n2. **Read the diff as though a competent stranger submitted it.** Would you merge this on the strength of a green build? You would not. The standard does not change because the author is a model.\n\n3. **Record the why at the moment you decide it.** A minute at the time, unrecoverable later. The guard clause with no reason attached is the most expensive artefact in a legacy codebase, because the next reader, human or agent, will delete it confidently and be right about the code and wrong about the system.\n\n4. **Build something small without assistance now and then.** Not on principle, as a measurement. There is no other way to find out how much you have quietly stopped being able to do.\n\n5. **Ask what happens on the second delivery.** This one is mine, and it is the only thing in this post that is. Apply it to every change that touches money. It is narrow enough to ask every time, and it covers a large share of what I have seen go wrong in this kind of code.\n\nThat is four borrowed and one added, which is roughly the ratio this whole post runs at. I would rather say so than let the framing imply otherwise.\n\n## Where this does not apply\n\nMost of what I write gets none of this.\n\nScaffolding, one-off scripts, admin screens, test fixtures, a migration for a reporting table. Delegate all of it and do not feel virtuous about reviewing it line by line. The cost of not understanding a throwaway script is that you throw it away.\n\nThere is a tension here with something else I believe. I have argued that [boring, legible code](/blog/boring-code-is-a-feature-you-ship-to-your-future-self) is worth defending everywhere, and that a competent stranger should be able to change any part of a system without archaeology. That still holds, and the two are reconcilable only if you separate them. Write everything legibly. Spend the expensive kind of attention, the slow adversarial what-if-this-runs-twice kind, unevenly.\n\nUnderstanding is finite. The mistake is not offloading. It is offloading at the same rate in the directory that issues refunds as in the directory that renders a settings page.\n\n## The takeaway\n\nThe research on cognitive debt is getting better and the general advice attached to it is fine as far as it goes. What it skips is that the interest rate varies enormously by what the code does. Somewhere in your system is a path where not understanding something costs a customer money today instead of costing you an afternoon next quarter, and that path deserves a different standard. The standard is not to think harder. It is a small number of questions asked every single time.\n\nSomebody has to be able to reconstruct the money path from first principles. If that is nobody, the tools did not cause the problem, but they made it a great deal easier to arrive at.\n\n## FAQ\n\n**What is the difference between cognitive offloading and cognitive surrender?**\nOffloading is handing over the work while keeping the judgement, so you still form your own view and can override the output. Surrender is when you stop forming an independent view at all, so there is nothing left to check against. The distinction is from Steven Shaw and Gideon Nave's Wharton paper on AI and reasoning, and Addy Osmani's [cognitive surrender](https://addyosmani.com/blog/cognitive-surrender/) is the clearest explanation of it for engineers.\n\n**Does AI-assisted coding make engineers worse?**\nOn unfamiliar material the evidence points that way. Anthropic's [randomised trial](https://www.anthropic.com/research/AI-assistance-coding-skills) reported that engineers learning a new Python library with an assistant scored around 50% on a comprehension quiz against roughly 67% for those working by hand, with the largest gap on debugging, and close to no speed advantage. Within the assisted group, the people who used the model to build comprehension scored well, whether they asked only conceptual questions or generated code and then asked for an explanation. The worst scores went to those who generated and moved on. That within-group comparison is observational and involves very few participants, so treat it as a direction and not a law.\n\n**Why does this matter more in billing code than elsewhere?**\nBecause revenue failures are quiet and immediate. A broken page raises an error somebody sees; a renewal that fails to extend an expiry reports success. The debt is charged as a wrong payment to a real customer instead of as a slower refactor two quarters out.\n\n**What is intent debt?**\nThe goals, constraints and rationale behind a system that were never written down outside somebody's head. The term is Margaret-Anne Storey's, from her [triple debt model](https://arxiv.org/abs/2603.22106), which separates it from technical debt in the code and cognitive debt in a team's shared understanding. It is the only one of the three an agent cannot repay: asked why a guard clause exists, a model will produce a confident answer whether or not it knows.\n\n**How should I review AI-generated billing code?**\nWrite down what you expect the change to look like before you read it. Read the diff as though a competent stranger submitted it and you had to defend merging it. Record the reason for anything defensive next to the code, so the next reader does not delete it. And ask what the change does if it runs twice, because with at-least-once delivery that is normal operation and not an edge case.\n\n**Can tooling fix this, with better tests or stricter review?**\nNot on its own, as far as the evidence goes. Alami, Zahedi and Krancher's [study of psychological safety in agile teams](https://doi.org/10.1016/j.infsof.2023.107267) found openness, absence of blame, collective decision-making and leadership ownership produced it, while team autonomy, slack time and technical practices providing a safety net did not reach significance. That is an absence of evidence and not proof of no effect, and other work disagrees about autonomy. Treat it as a caution against assuming a safety net is sufficient.\n\n**How do I know if I have crossed the line?**\nTry to reconstruct a recent design decision without opening the code or the agent. If you can say why it was made and what it was defending against, you offloaded. If you can only say it seemed reasonable at the time, you did not.\n",[22,25,28,31,34,37,40],{"q":23,"a":24},"What is the difference between cognitive offloading and cognitive surrender?","Offloading is handing over the work while keeping the judgement, so you still form your own view and can override the output. Surrender is when you stop forming an independent view at all, so there is nothing left to check against. The distinction is from Steven Shaw and Gideon Nave's Wharton paper on AI and reasoning, and Addy Osmani's cognitive surrender is the clearest explanation of it for engineers.",{"q":26,"a":27},"Does AI-assisted coding make engineers worse?","On unfamiliar material the evidence points that way. Anthropic's randomised trial reported that engineers learning a new Python library with an assistant scored around 50% on a comprehension quiz against roughly 67% for those working by hand, with the largest gap on debugging, and close to no speed advantage. Within the assisted group, the people who used the model to build comprehension scored well, whether they asked only conceptual questions or generated code and then asked for an explanation. The worst scores went to those who generated and moved on. That within-group comparison is observational and involves very few participants, so treat it as a direction and not a law.",{"q":29,"a":30},"Why does this matter more in billing code than elsewhere?","Because revenue failures are quiet and immediate. A broken page raises an error somebody sees; a renewal that fails to extend an expiry reports success. The debt is charged as a wrong payment to a real customer instead of as a slower refactor two quarters out.",{"q":32,"a":33},"What is intent debt?","The goals, constraints and rationale behind a system that were never written down outside somebody's head. The term is Margaret-Anne Storey's, from her triple debt model, which separates it from technical debt in the code and cognitive debt in a team's shared understanding. It is the only one of the three an agent cannot repay: asked why a guard clause exists, a model will produce a confident answer whether or not it knows.",{"q":35,"a":36},"How should I review AI-generated billing code?","Write down what you expect the change to look like before you read it. Read the diff as though a competent stranger submitted it and you had to defend merging it. Record the reason for anything defensive next to the code, so the next reader does not delete it. And ask what the change does if it runs twice, because with at-least-once delivery that is normal operation and not an edge case.",{"q":38,"a":39},"Can tooling fix this, with better tests or stricter review?","Not on its own, as far as the evidence goes. Alami, Zahedi and Krancher's study of psychological safety in agile teams found openness, absence of blame, collective decision-making and leadership ownership produced it, while team autonomy, slack time and technical practices providing a safety net did not reach significance. That is an absence of evidence and not proof of no effect, and other work disagrees about autonomy. Treat it as a caution against assuming a safety net is sufficient.",{"q":41,"a":42},"How do I know if I have crossed the line?","Try to reconstruct a recent design decision without opening the code or the agent. If you can say why it was made and what it was defending against, you offloaded. If you can only say it seemed reasonable at the time, you did not.","14 min read",14,{"slug":46,"title":47,"description":48,"date":49,"updated":9,"tags":50,"pillar":16,"draft":17,"image":18,"imageAlt":19,"markdown":55,"faq":56,"readingTimeText":75,"readingTimeMinutes":76},"rubber-duck-debugging-in-php","Rubber duck debugging in PHP: narrate the sequence, not the lines","Narrate the execution, not the file: what fires, in what order, in whose process. The four questions that find hook, observer and queue bugs in WordPress and Laravel.","2026-08-01T00:00:00.000Z",[51,52,53,54,13],"debugging","php","laravel","wordpress","\n## TL;DR\n\nTo rubber duck debug PHP, narrate the execution instead of the file, out loud, in four steps: **(1)** where does this start, request, cron tick, queue worker, webhook, WP-CLI; **(2)** what fires between the start and my code, every hook, middleware, observer and listener, named, not \"the usual stuff\"; **(3)** what do I believe is true when my code runs; **(4)** whose process is this, and does it run once. Reading the file aloud line by line only makes you confident about a fragment, because in WordPress and Laravel the program is assembled at runtime and the bug is usually in step two or step four.\n\nEveryone has met the duck. You explain a bug out loud to an inanimate object, and somewhere around the third sentence you stop, because you have heard yourself say something that is not true. Andrew Hunt and David Thomas popularised the technique in [The Pragmatic Programmer](https://pragprog.com/titles/tpp20/the-pragmatic-programmer-20th-anniversary-edition/) in 1999, describing a developer who carried a duck and explained his code to it line by line. It works because speech is slower than reading, and because explaining forces you to state intent, not just mechanism.\n\nI want to keep the mechanism and change the unit. Because in the PHP I work in, [WordPress plugins with large install bases](/blog/you-dont-rewrite-a-plugin-with-a-big-install-base) and Laravel applications that take money, the line is the wrong unit, and line-by-line narration has a poor hit rate. It took me a while to work out why.\n\n## The lines are not the program\n\nOpen a WordPress plugin's `class-license-handler.php` and read it aloud. You will narrate a method that validates a licence key and returns a boolean. Every line is correct. The bug is that something else called `add_filter( 'pre_http_request'... )` in a completely different file, and your validation request never left the server.\n\nSame in Laravel. Read a `SubscriptionController` aloud and every line is right. The bug is an observer on the model that fires on `updated`, dispatching a job that a queue worker picks up while it is still holding a copy of the framework it booted three deploys ago.\n\nThis is the shape of most of the hard PHP bugs I have been paid to find. The code in front of you is not wrong. The code in front of you is **incomplete**, and the missing part is not on screen. WordPress makes this explicit with hooks; Laravel makes it elegant with events, observers, middleware and the container. Both mean the same thing for debugging: **the program is assembled at runtime and the file you are reading is a fragment of it.**\n\nNone of that is unique to PHP, Rails callbacks, Django signals and Node middleware produce the same problem. It is just that WordPress and Laravel are where I meet it daily, and where the specific hooks worth naming are ones I can name.\n\nReading a fragment aloud, carefully, in order, will make you more confident about the fragment. That is worse than useless when the fragment was never the problem.\n\n## Ask the duck a different question\n\nThe version that works is not \"what does this line do\". It is:\n\n> **What happens, in what order, in which process, and what do I believe is true at each step?**\n\nYou are no longer narrating a file. You are narrating an execution.\n\n### One: where does this start\n\nA request? A cron tick? A queue worker? A webhook from Stripe? WP-CLI? Say it.\n\nA lot of the bugs I find in scheduled work die right here, because `wp-cron` is not cron, it fires on a page load, so on a quiet site it fires late or not at all, and the developer has spent two days debugging the job body. Say which one you have, though: if the host sets [`DISABLE_WP_CRON`](https://developer.wordpress.org/plugins/cron/hooking-wp-cron-into-the-system-task-scheduler/) and runs a real system cron, as most managed WordPress hosts do, your problem is the opposite one. It fires exactly on time, and it can now fire twice at once.\n\n### Two: what fires between the start and my code\n\nEvery hook, middleware, observer and listener in between. Out loud. Not \"the usual stuff\", name them. This is the step people skip because it is boring, and it is the step that finds the bug.\n\n### Three: what do I believe is true when my code runs\n\nThat the user is authenticated. That the licence row exists. That `$order->total` is in cents. That this runs once. Every one of those is an assumption, and saying it out loud is what turns it back into a question.\n\n### Four: whose process is this\n\nThe web request and the queue worker are different PHP processes with different memory. Laravel is explicit about it: [queue workers are long-lived, so they do not see changes to your code until they are restarted](https://laravel.com/docs/queues#queue-workers-and-deployment). The worker is running whatever your last `queue:restart` gave it, which is not necessarily what you deployed.\n\nSaying \"and then the job runs, in a different process, holding an older copy of the app\" is the sentence that has saved me the most hours.\n\n## What that sounds like on a real bug\n\nA licensing plugin. Customers report that a small number of renewals do not extend the expiry date. Not all, a few, unpredictably. The renewal handler looks fine and has looked fine to three people.\n\nLine-by-line narration produces: \"it loads the licence, adds a year, saves.\" Correct, and useless.\n\nSequence narration produces something like this, and I mean said out loud, badly, to nobody:\n\n> The payment provider sends a webhook. It hits my route. **Which can arrive more than once, because delivery is at-least-once and providers redeliver when they do not get a timely 2xx.** Then middleware runs. Then I look up the licence by key. Then I add a year to the *current* expiry. Then I save. And I believe this runs once per payment.\n\nThe handler is right. The assumption that it runs once is wrong, and the two failure modes look nothing alike. When a redelivery lands *while* the first attempt is still running, both reads see the same current expiry, both add a year, and the second write overwrites the first, the customer paid once, got one year, and the renewal silently did nothing the next time round. When the redelivery lands *after* the first write commits, they stack and add two years, and nobody files a ticket about getting too much.\n\nOnly the first one matched the reported symptom. I found it by saying a sentence I had not planned to say.\n\nThe fix was not in the handler at all. The renewal is now keyed on the provider's event id behind a unique constraint, so a redelivery hits the index and returns `204` without touching the expiry, the same [database-enforced idempotency I reach for in any payments path](/blog/six-payments-edge-cases-that-bite-at-2am). The duplicate event ids had been in the logs the whole time. Nobody had looked, because nobody had said the sentence that made them worth looking for.\n\n## Why saying it beats thinking it\n\nReading code you wrote is closer to recognition than to reading. You see the shape of your own `foreach`, it matches your memory of what it should be, and you move on. That is efficient and it is exactly the failure mode you need to break.\n\nSpeech will not let you do it. You cannot say \"and then the job runs\" without your own brain asking *where*. The duck is not analysing anything. The duck is a device for making you use a slower, more honest part of your attention.\n\nWhich is also why the duck beats a colleague for the first pass. A colleague fills in your gaps, they know your codebase, they nod at \"and then the usual auth stuff\", and the assumption you needed to hear survives untested. The duck nods at nothing.\n\n## Where it does not help\n\nIt will not find a bug in code you have never read. If the failure is inside a dependency, or in a part of the plugin you have not opened, you cannot narrate a sequence you do not know yet. Read first, narrate second.\n\nIt will not survive being done in your head. Every time I have thought \"I will just do this mentally\", I have skipped step two, the boring one, the one that finds the bug. Out loud, or written down, or typed into a scratch file. The medium is not the point; not doing it silently is.\n\nAnd it will not replace evidence. Once the duck gives you a candidate, go and prove it: log the webhook id, count the rows, check whether the transient you assumed expired is actually gone, in `wp_options` if the site has no object cache, and in Redis if it does, which is a different query and the reason people give up on that one. The duck produces hypotheses, and a hypothesis you did not verify is just a more confident guess. That is the same discipline that makes [boring code worth defending](/blog/boring-code-is-a-feature-you-ship-to-your-future-self), you want the version whose behaviour you can check, not the version you find convincing.\n\n## The version I would give a junior PHP developer\n\nSkip the duck. Open a text file next to your editor and fill this in, in plain sentences:\n\n```text\n1. This starts when ________.\n2. Before my code runs, these things fire: ________.\n3. When my code runs, I believe ________ is true.\n4. This runs in the ________ process, once / more than once.\n```\n\nFilled in on the renewal bug above, it looked like this:\n\n```text\n1. This starts when the payment provider POSTs to /webhooks/payments.\n2. Before my code runs, these things fire: route middleware, signature\n   verification, the subscription observer on `updated`.\n3. When my code runs, I believe the licence row exists and the expiry\n   is the one the customer is paying to extend.\n4. This runs in the web process, and I believe once.  \u003C- this line was the bug.\n```\n\nIn WordPress and Laravel, more often than not, one of those four lines is the bug, and it is usually line two or line four.\n\n## The takeaway\n\nLine-by-line narration makes you confident about a fragment. In WordPress and Laravel the program is assembled at runtime, so the fragment was never the bug. Narrate the execution instead: where it starts, what fires before your code, what you believe is true when it runs, and whose process it runs in. Then go and prove the candidate with a log line or a row count, because an unverified hypothesis is just a more confident guess.\n\n## FAQ\n\n**How do I use rubber duck debugging on PHP code?**\nDo not read the file aloud line by line. Narrate the execution: say where the request starts, every hook, middleware, observer and listener that fires before your code, what you believe is true when your code runs, and which process it runs in. In WordPress and Laravel the bug is usually in the order, not the lines.\n\n**Why does line-by-line rubber ducking fail in WordPress and Laravel?**\nBecause the file you are reading is a fragment. Hooks, events, observers, middleware and queued jobs mean the program is assembled at runtime from files you never opened, so reading that fragment carefully only makes you more confident about the part that was never wrong.\n\n**What four questions should I ask out loud?**\nWhere does this start. What fires between the start and my code. What do I believe is true when my code runs. Whose process is this, and does it run once. In WordPress and Laravel the bug is most often in question two or question four.\n\n**Does rubber duck debugging work if I do it in my head?**\nNo. Doing it silently is how you skip the boring step, which is naming every hook and listener in between, and that is the step that finds the bug. Say it out loud, write it down, or type it into a scratch file.\n\n**Where does rubber duck debugging come from?**\nAndrew Hunt and David Thomas popularised it in *The Pragmatic Programmer* in 1999, describing a developer who carried a rubber duck and explained his code to it line by line. The book popularised the anecdote; the name came from the community afterwards.\n\n**When does rubber duck debugging not help?**\nWhen the failure is in code you have never read, because you cannot narrate a sequence you do not know. Read first, narrate second. And it never replaces evidence: once the duck gives you a candidate, log the webhook id or count the rows and prove it.\n",[57,60,63,66,69,72],{"q":58,"a":59},"How do I use rubber duck debugging on PHP code?","Do not read the file aloud line by line. Narrate the execution: say where the request starts, every hook, middleware, observer and listener that fires before your code, what you believe is true when your code runs, and which process it runs in. In WordPress and Laravel the bug is usually in the order, not the lines.",{"q":61,"a":62},"Why does line-by-line rubber ducking fail in WordPress and Laravel?","Because the file you are reading is a fragment. Hooks, events, observers, middleware and queued jobs mean the program is assembled at runtime from files you never opened, so reading that fragment carefully only makes you more confident about the part that was never wrong.",{"q":64,"a":65},"What four questions should I ask out loud?","Where does this start. What fires between the start and my code. What do I believe is true when my code runs. Whose process is this, and does it run once. In WordPress and Laravel the bug is most often in question two or question four.",{"q":67,"a":68},"Does rubber duck debugging work if I do it in my head?","No. Doing it silently is how you skip the boring step, which is naming every hook and listener in between, and that is the step that finds the bug. Say it out loud, write it down, or type it into a scratch file.",{"q":70,"a":71},"Where does rubber duck debugging come from?","Andrew Hunt and David Thomas popularised it in *The Pragmatic Programmer* in 1999, describing a developer who carried a rubber duck and explained his code to it line by line. The book popularised the anecdote; the name came from the community afterwards.",{"q":73,"a":74},"When does rubber duck debugging not help?","When the failure is in code you have never read, because you cannot narrate a sequence you do not know. Read first, narrate second. And it never replaces evidence: once the duck gives you a candidate, log the webhook id or count the rows and prove it.","11 min read",11,{"slug":78,"title":79,"description":80,"date":81,"updated":9,"tags":82,"pillar":85,"draft":17,"image":86,"imageAlt":87,"markdown":88,"faq":89,"readingTimeText":108,"readingTimeMinutes":109},"when-to-automate-refunds","When to automate refunds, and when to keep them manual","Automating refunds is easy, and early on it deletes your best customer feedback. Why manual refunds pay, and the four signals it is time to automate.","2026-07-28T00:00:00.000Z",[83,84,85,14],"refunds","chargebacks","founder-engineering","/blog-covers/founder-engineering.png","Founder engineering cover","\n## TL;DR\n\nKeep refunds manual until they stop teaching you anything: a refund request is the highest-signal message a customer will send you, and a one-click button deletes it before you read it. Automate when the surprises stop.\n\nI build billing systems for a living. Automating a refund is not hard: you call the provider's refund endpoint, you revoke the entitlement when it confirms, you write the audit row, you send the email. I have shipped that flow more than once and I would ship it again.\n\nAnd when an early-stage founder asks me to build it, I usually tell them to wait.\n\nNot because the code is risky, and not because a human is more reliable than an API call. Because in the first few hundred customers, a refund request is the most information-dense message you will ever receive, and a one-click button is a machine for deleting it before anyone reads it.\n\n## A refund request is feedback you cannot buy\n\nAlmost nobody tells you why they left. They churn silently, the renewal fails, the seat goes quiet. Surveys go unanswered. The people who never bought never explain themselves.\n\nA refund request is different. Someone paid you money, used the thing, decided it was wrong, and then cared enough to come back and ask for the money returned. That person is, briefly, willing to talk to you. They have a reason, and it is fresh and specific. There is no other moment in the funnel where a stranger tells you the truth about your product at that resolution.\n\nAn automated refund flow takes that moment and converts it into a row in a table. You get a number. You do not get the sentence that would have changed your roadmap.\n\nAt scale that trade is correct: once volume is high enough, the marginal request stops teaching you anything new and the support cost becomes real. In the first few hundred customers, you are trading away the thing you most need for a cost you can still absorb by hand.\n\nEarly on a company is short of almost everything at once: money, time, proof, and honest signal about whether the idea is right. The refund conversation is one of the few places where three of those arrive in the same message.\n\n## Ask one question when a refund request arrives\n\nKeeping refunds manual is a smaller practice than it sounds. When a refund request arrives, a person replies and asks what happened.\n\nNot \"please state your reason for requesting a refund as per our policy.\" Not a form with five options you wrote. A short, plain message from a human: what went wrong, or what did you expect that this did not do.\n\nTwo things matter about who sends it.\n\n**Put your best communicator on refunds, not your fastest responder.** In most teams I have worked inside, refunds go to whoever has capacity, which usually means whoever is least busy, which often means whoever is most junior. That is backwards. A refund reply is the hardest message your support surface produces: the customer is already disappointed, is expecting friction, and is braced for you to make this difficult. It needs somebody who reads tone well, who does not get defensive about the product, and who can tell the difference between \"this is broken\" and \"this is not for me.\" It is the same instinct behind [hiring boring for the code that moves money](/blog/the-first-hire-who-touches-your-billing-should-be-boring): you are selecting for judgment under pressure, not for speed.\n\n**They must be someone who is allowed to say yes.** If the person answering believes they are measured on refunds prevented, they will apply pressure and the customer will feel it. The question exists to understand, not to build a case. A customer who senses they are being handled stops telling you anything useful and escalates instead.\n\nThat is the whole mechanism: one honest question, from someone good at asking it, who is free to accept the answer.\n\n## The three reasons behind most refund requests\n\nThe reason behind a refund request is usually one of three.\n\n- **A glitch they hit and assumed was the product.** A webhook that never fired, so their upgrade did not apply. A license that failed to activate on a staging domain. A charge that looked duplicated because retry logic was noisy. This is the version I care most about, because it is not really a refund request. It is a bug report with money attached, from a customer telling you about a failure your monitoring missed. These are exactly the [payment edge cases that bite at 2am](/blog/six-payments-edge-cases-that-bite-at-2am), found by the least convenient method available.\n- **A feature that exists but was not findable.** They wanted the thing, the thing is there, nothing in the product pointed at it. That is an onboarding failure wearing a refund costume, and it is repeating silently for every customer who did not bother to write in.\n- **A mismatch between what your marketing implied and what you built.** This one is the most painful to hear and the most valuable, because it sits upstream of everything. No support process fixes a promise you should not have made.\n\n![How a refund request resolves: one question, then three cases. A glitch or a missing feature is recoverable, a real gap gets an honest answer, and \"I changed my mind\" is refunded with no follow-up.](/blog-images/refund-conversation-flow.svg)\n\nThe first two are recoverable, and this is where the conversation earns its cost. When the cause is a glitch or something in the product I can actually fix, saying \"that was a bug, here is the fix, it is deployed, do you still want the refund?\" resolves it roughly seven times in ten, across the subscription and licensing products I maintain. Not because anyone was talked round. Because the problem went away. That is repair, not persuasion.\n\nThe third is not recoverable by a support reply, and pretending otherwise is how a refund queue turns into a retention funnel. It is also the one that should change something upstream, in the same way that deciding [whether to build or buy your billing](/blog/build-vs-buy-for-billing) is a positioning decision before it is a technical one.\n\n## What one refund request actually built\n\nThe clearest example I have comes from the affiliate plugin I maintain.\n\nA refund request arrived with no explanation attached, just the request. So we asked what happened.\n\nThe answer was specific: they needed a disclosure line to appear automatically wherever an affiliate link was rendered on their site. Not on a policy page. At the link. That is not a preference, it is what the guidance actually requires: the FTC is explicit that a disclosure belongs [with the endorsement message itself](https://www.ftc.gov/business-guidance/resources/disclosures-101-social-media-influencers), and that disclosures are likely to be missed if they sit only on an About page or anywhere a reader has to click through to find. Their own review had flagged it, and the plugin did not do it.\n\nThat was not a bug and not a discoverability problem. It was a real gap, and the customer was right.\n\nSo I told them exactly that: it is missing, you are correct that it should exist, and here is a beta build that does it. They installed it, it solved their problem, and they withdrew the refund request themselves. I never asked them to.\n\nThen comes the part I think matters more, and it is the part founders usually get wrong in the other direction.\n\nI did not ship that feature to everyone the following week.\n\nOne customer asking for something is a data point, not a roadmap. Building it into the product properly meant deciding where the disclosure text lives, how it is translated, whether it is per-link or per-page, what happens on themes that strip markup, and whether it should be on by default for everyone who updates. That is a real feature with real edges, and I had exactly one request for it. So the beta stayed with the customer who needed it, and I waited to see whether anyone else asked.\n\nOthers did. When the same request arrived from unrelated customers, it stopped being one person's edge case and became a documented, tested, default-on feature. The refund conversation did not just save one subscription. It found the feature roughly two releases before the roadmap would have.\n\nOne honest caveat, because this move can be abused. A beta handed over to stop a refund is a promise. If you use it to buy time and then never ship the real thing, you have not saved a customer, you have converted a refund into a slower and more personal betrayal. Give the build only if you would be comfortable being held to it.\n\n## Why \"I changed my mind\" is a complete refund request\n\nHere is the part that matters more than any recovery rate.\n\nIf the answer is \"I changed my mind,\" that is a complete answer. It is not an objection to handle. There is no follow-up question. You refund it.\n\nThe pressure to do otherwise is real and worth naming. A refund at an early stage genuinely hurts. When monthly revenue is small a single refund is a visible dent, and it arrives feeling like a verdict on the work. Founders take it personally because at that stage the product is not separate from them yet. That feeling is legitimate.\n\nIt is also entirely your problem, not the customer's.\n\n> A refund hurts the founder. That does not give the founder any right to hurt the customer's pocket.\n\nSomeone who does not want your product should not be holding your invoice. Every extra step you put between them and their money converts your discomfort into their cost, and they can tell exactly what you are doing.\n\nThis is also why I am wary of the \"convince them\" framing even though convincing works. The moment recovery becomes the goal rather than a by-product of fixing something real, the conversation becomes a retention funnel, and customers recognize one instantly. You keep a little revenue and lose the thing that made the question worth asking.\n\nThe practical test I use: would I be comfortable if the customer published this entire email thread? If any part of the reply only works because they will not compare notes with anyone, it should not be sent.\n\n## Refund because they asked, not because it is cheaper\n\nSet aside how it feels, because the arithmetic agrees anyway.\n\nA customer denied a refund often does not give up; they open a dispute with their card issuer instead. That is materially worse for you. Stripe's documentation is explicit that a dispute [pulls the payment amount and a network dispute fee from your balance](https://docs.stripe.com/disputes) while you assemble evidence, and your dispute rate is a number the card networks watch. You can end up paying more than the refund, plus your own time, plus a metric that follows you.\n\nThere is also a version that shows up in no dashboard: the person who got an easy refund and a straight answer sometimes returns when the product finally fits, and meanwhile does not tell people you were difficult about money. I cannot put a number on that and will not pretend to. The downside is easier to price, because a public complaint about a company that would not return sixty dollars costs far more than sixty dollars.\n\nOne caution, since the arithmetic is the weaker argument: if \"it is cheaper than a chargeback\" becomes your reason for refunding, you have rebuilt the retention funnel with better maths. Refund because they asked. That the economics agree is a convenience, not the justification.\n\n## Keeping the decision manual is not an excuse for a sloppy refund path\n\nNone of this changes what the code must do. Whoever presses the button, the execution has to be boring and correct.\n\n```php\n// $requestKey is supplied by the caller and is unique per decision, not derived\n// from the amount: two legitimate partial refunds of the same value are not duplicates.\npublic function refund(Payment $payment, Money $amount, string $reason, string $requestKey): Refund\n{\n    // 1. Record the intent first, and let a unique index on (payment_id, request_key)\n    //    be the actual guard. A double-clicked button and a retried job land here twice.\n    try {\n        $refund = Refund::create([\n            'payment_id'   => $payment->id,\n            'request_key'  => $requestKey,\n            'amount_cents' => $amount->cents(),  // integer minor units, never a float\n            'reason'       => $reason,           // what the customer actually said\n            'status'       => 'pending',\n        ]);\n    } catch (UniqueConstraintViolationException) {\n        // Already handled this exact decision. Return it, do not refund twice.\n        return Refund::where('payment_id', $payment->id)\n            ->where('request_key', $requestKey)\n            ->firstOrFail();\n    }\n\n    // 2. Call the provider OUTSIDE any transaction. A network call must never hold a\n    //    row lock, and a rolled-back commit must never strand a refund that really happened.\n    $result = $this->gateway->refund($payment->charge_id, $amount, $requestKey);\n\n    // 3. Confirm and revoke together, so you can never have returned the money while\n    //    the account still works. The webhook runs this same step idempotently, which\n    //    is what recovers the case where the response above is lost in flight.\n    DB::transaction(function () use ($refund, $result) {\n        $refund->update(['status' => 'succeeded', 'gateway_ref' => $result->id]);\n        $refund->payment->subscription->revokeAccess();\n    });\n\n    return $refund->refresh();\n}\n```\n\nThree details do the work. The idempotency key comes from the decision rather than the amount, because the provider will happily let you [issue several partial refunds against one charge](https://docs.stripe.com/refunds) so long as they do not exceed the original, and keying on value would make two legitimate refunds collide. The amount is [derived rather than divided](/blog/store-money-as-integer-cents-never-a-float) and stored as integer minor units, which is exactly the arithmetic a tired human gets wrong at a keyboard. And the gateway call sits outside the transaction, because the one failure you cannot recover from is a provider that has moved real money while your database rolled back and forgot.\n\n![Where the transaction boundary belongs: record the intent in a transaction, call the provider outside any transaction, then confirm and revoke together in a second transaction, with the webhook repeating the last step idempotently.](/blog-images/refund-transaction-boundary.svg)\n\nThat is also why the refund path is [where I read a codebase's soul](/blog/the-refund-path-is-where-i-read-a-codebases-soul). Keeping the decision human raises the bar on the code rather than lowering it, because now a person is triggering it under time pressure with a customer waiting.\n\n## When to automate refunds: four signals\n\nFour signals say you have outgrown manual refunds:\n\n- **The last thirty refund conversations taught you nothing new.** When the answers stop surprising you, the signal is extracted and you are paying for it out of habit.\n- **Response time has become the complaint.** If people wait a day for a refund because a human is asleep, the process now produces the resentment it was meant to prevent.\n- **Someone is deciding by rule rather than judgment.** The moment your team is applying a flowchart, the human adds nothing a function could not, and you should ship the function.\n- **The volume is genuinely painful.** This is the weakest reason on its own, and the one most often reached for first.\n\nI am not arguing for a human in the loop forever. Manual refunds cost real time and that cost grows, and past a certain volume the human is adding nothing a function could not.\n\nWhen you do automate, keep a smaller version of the conversation: one optional free-text field, read by an actual person weekly. The value was never in the gatekeeping. It was in reading the sentence.\n\n## The takeaway\n\nAutomating refunds is a solved engineering problem and usually the wrong thing to solve first. The request is the most honest message a customer will ever send you, and it is worth a human reply for exactly as long as those replies keep teaching you something. Ask what happened, put your best communicator on it rather than your quickest, fix the thing if it is fixable, and refund without friction the moment someone says they changed their mind. Automate when the surprises stop, not when the discomfort starts. The discomfort of a refund belongs to you. The money belongs to them.\n\n## FAQ\n\n**When should a small company automate refunds?**\nWhen the last thirty refund conversations produced nothing you did not already know, when response time has become the complaint, or when your team is deciding by flowchart rather than judgment. Volume alone is the weakest reason, though it is the one most often used first. Automate when the surprises stop.\n\n**Isn't manual refund handling just a way to make refunds harder?**\nIt is if you do it badly. The test is whether the person replying is allowed to say yes immediately. If they are measured on refunds prevented, you have built a retention funnel and customers will read it as one. If they are measured on understanding what happened, you have built a feedback channel.\n\n**What if a customer refuses to say why they want a refund?**\nRefund them. The question is an invitation, not a condition. Making an answer mandatory turns a request into an interrogation and guarantees the answer you get is not the true one.\n\n**Should you handle refunds manually for free trials and small monthly plans?**\nLess so. The lower the amount, the weaker the case for spending a human reply on it, and trials rarely produce a refund request at all. This matters most for annual plans, licenses, and anything large enough that the customer thought carefully before both buying and asking.\n\n**Should you build a feature just because one customer asked during a refund request?**\nNo. One request is a data point, not a roadmap. Give that customer a beta or a workaround if you can, then wait to see whether the same request arrives from unrelated customers before you build it properly. If you hand over a beta, treat it as a promise you will be held to.\n\n**When is it right to push back on a refund request?**\nOnly when you can remove the reason. If the cause was a bug you have now fixed, saying so and offering the choice is fair. If the cause is that they no longer want the product, there is nothing to remove and nothing to discuss.\n",[90,93,96,99,102,105],{"q":91,"a":92},"When should a small company automate refunds?","When the last thirty refund conversations produced nothing you did not already know, when response time has become the complaint, or when your team is deciding by flowchart rather than judgment. Volume alone is the weakest reason, though it is the one most often used first. Automate when the surprises stop.",{"q":94,"a":95},"Isn't manual refund handling just a way to make refunds harder?","It is if you do it badly. The test is whether the person replying is allowed to say yes immediately. If they are measured on refunds prevented, you have built a retention funnel and customers will read it as one. If they are measured on understanding what happened, you have built a feedback channel.",{"q":97,"a":98},"What if a customer refuses to say why they want a refund?","Refund them. The question is an invitation, not a condition. Making an answer mandatory turns a request into an interrogation and guarantees the answer you get is not the true one.",{"q":100,"a":101},"Should you handle refunds manually for free trials and small monthly plans?","Less so. The lower the amount, the weaker the case for spending a human reply on it, and trials rarely produce a refund request at all. This matters most for annual plans, licenses, and anything large enough that the customer thought carefully before both buying and asking.",{"q":103,"a":104},"Should you build a feature just because one customer asked during a refund request?","No. One request is a data point, not a roadmap. Give that customer a beta or a workaround if you can, then wait to see whether the same request arrives from unrelated customers before you build it properly. If you hand over a beta, treat it as a promise you will be held to.",{"q":106,"a":107},"When is it right to push back on a refund request?","Only when you can remove the reason. If the cause was a bug you have now fixed, saying so and offering the choice is fair. If the cause is that they no longer want the product, there is nothing to remove and nothing to discuss.","16 min read",16,{"slug":111,"title":112,"description":113,"date":114,"updated":115,"tags":116,"pillar":85,"draft":17,"image":86,"imageAlt":87,"markdown":121,"faq":122,"readingTimeText":138,"readingTimeMinutes":139},"cost-cutting-is-motion-not-a-strategy","Cost cutting is motion, not a strategy","Cutting costs moves the numbers, not the company. The long game goes to people who stay to hold context and balance operators with entrepreneurs.","2026-07-13T00:00:00.000Z","2026-07-26T00:00:00.000Z",[117,118,119,120],"strategy","teams","engineering-leadership","long-term","\n## TL;DR\n\nCutting costs moves the numbers, not the company. The long game is won by people who stay long enough to hold context, and by teams that balance operators with entrepreneurs, and questions with shipping.\n\nFrederick Vanbrabant wrote a sharp piece this month called [Movement is not progress](https://frederickvanbrabant.com/blog/2026-07-10-movement-is-not-progress/), about how so many companies mistake activity for strategy. Cut costs, optimize the bottom line, then look up and realize the company is smaller but no clearer about where it is going. I have watched the same pattern from the engineering side, on the code and on the people who keep it alive, and I want to add the part I keep running into.\n\n## Movement is easy to measure, which is the trap\n\nA cost cut produces a number this quarter. That is its appeal and its whole danger. You can point at it in a meeting. Strategy, the slower kind, produces almost nothing you can show for a long time, and then produces everything at once. So the reflex under pressure is to reach for the thing you can measure, and Frederick is right that this is a dog catching the car: motion with no idea what to do once you arrive.\n\nThe engineering version is familiar. The build is slow, so someone deletes the tests. The number moves, the build is faster, and the thing that made the codebase safe to change is gone. Nobody decided to make the product fragile. They decided to make a number better, and fragility was the invisible cost that did not show up on the same page. This is Goodhart's Law in one sentence: [when a measure becomes a target, it stops being a good measure](https://en.wikipedia.org/wiki/Goodhart%27s_law). Optimize solvency alone and you will trade the company's future to make this quarter look solved.\n\n## The long game needs long-running players\n\nHere is the thing cost cutting hits that rarely makes it onto the spreadsheet: context. The most expensive asset in any product that has been alive for a few years is not the code. It is the accumulated knowledge of why the code is the way it is, which lives in the people who were there when the decisions were made. Why that refund path throws instead of silently succeeding. Why that migration is additive only. Why that one client integration cannot be touched on a Friday.\n\nYou cannot hire that back in a quarter. When a cost cut churns out the people who hold it, the org keeps the code and loses the reasons, and the next team spends two years relearning what walked out the door. I have argued before that [you do not rewrite a system with a big install base](/blog/you-dont-rewrite-a-plugin-with-a-big-install-base), because the value is in the accumulated context, not the syntax. The same is true of a team. A long game is played by people who stay long enough to accumulate that context, and it compounds only if you let it. This is why I care so much about [boring code you ship to your future self](/blog/boring-code-is-a-feature-you-ship-to-your-future-self): durable code and durable teams are the same bet, that the person maintaining this in three years should not have to be a genius to be safe.\n\n## You need two kinds of people, not one\n\nThere is a temptation, especially when you are optimizing, to define the ideal employee and hire only that person. It is a mistake, because the work needs two temperaments that rarely live in the same body.\n\nSome people are operators. Give them a defined job and it gets done, reliably, on time, without drama, for years. They are the reason payroll runs and the invoices reconcile and the on-call rotation is covered. Some people are entrepreneurs in temperament, whether or not they ever start anything. They see the thing that is not there yet, they are restless with the status quo, and they will drag the team toward the next version of the product. A company of only operators is efficient and slowly becomes obsolete. A company of only entrepreneurs invents constantly and cannot keep the lights on. You need the blend, and you need to stop treating one temperament as the \"real\" contributor and the other as overhead. Cost cutting almost always eats the entrepreneurial half first, because their value is the hardest to put on a spreadsheet, and then everyone wonders why the roadmap went quiet.\n\n## Raising questions is not a virtue, and neither is silence\n\nThe same balance shows up one level down, in how people work. There is a fashion for celebrating the person who asks the hard question in the room, and a matching fashion for celebrating the person who just ships without fuss. Both are half right.\n\nSomeone who only ever raises questions and never commits becomes a tax on everyone's momentum. The meeting that could have ended in a decision ends in three more meetings. But someone who only ever ships, who never stops to ask whether this is the right thing to build, is how you get a beautifully engineered answer to a question nobody asked. I wrote a whole post about [the three questions I refuse to start billing code without](/blog/three-questions-before-i-write-billing-code), and the point of it is not that questions are free. It is that a few of the right ones, asked early, save you from expensive silence later, and then you stop asking and go build. The skill is knowing which mode the moment calls for, and a healthy team has people who lean both ways and the trust to switch.\n\n## Cost cutting has a place, as a step, not the plan\n\nI want to be as fair as Frederick was: cutting costs is sometimes exactly right. If the company is genuinely burning more than it can raise, discipline is not optional. The distinction is whether the cut is step one of a plan that has a step two, or whether it is the entire plan dressed up as one. A cut with a vision behind it buys you the room to get somewhere. A cut with nothing behind it just resets the clock until you have to do it again, with less trust from your customers and less context on your team each time. That is the difference between deciding [when to build and when to buy](/blog/build-vs-buy-for-billing) as part of a direction, and thrashing between them because the quarterly number demanded a story.\n\n## The takeaway\n\nMovement is not progress, and a company that only knows how to cut is only ever moving. The long game is won on the things that do not fit neatly in a quarter: the people who stay long enough to hold the context, the balance between operators who keep it running and entrepreneurs who push it forward, and the judgment to know when to raise a question and when to close it and ship. Cut when you must, but cut as one move inside a plan, not as the plan. The scale going down is not the same as getting stronger.\n\n## FAQ\n\n**Are you saying companies should never cut costs?**\nNo. Cutting costs is sometimes necessary and sometimes correct. The argument is that a cut should be one step inside a plan that has a next step, not the whole strategy. A cut with no vision behind it just resets the clock.\n\n**What does \"long-running players\" actually mean?**\nPeople who stay on a product long enough to accumulate the context of why it is built the way it is. That knowledge is the most expensive thing to rebuild and the first thing lost when you churn people to save money.\n\n**Isn't hiring only \"entrepreneurial\" people better for growth?**\nNo. A team of only entrepreneurs invents constantly but cannot keep the lights on, and a team of only operators runs smoothly into obsolescence. You need both temperaments, and you need to value the one that is harder to measure.\n\n**How is this related to writing durable code?**\nDirectly. Durable code and durable teams are the same bet: that the person maintaining this in three years should not need to be a hero to be safe. Cost cutting that destroys context breaks both.\n\n**Who is Frederick Vanbrabant and why reference him?**\nHe writes about the intersection of enterprise architecture, product, and business strategy. His post [Movement is not progress](https://frederickvanbrabant.com/blog/2026-07-10-movement-is-not-progress/) is what prompted this one, and this is my engineering-side companion to his argument.\n",[123,126,129,132,135],{"q":124,"a":125},"Are you saying companies should never cut costs?","No. Cutting costs is sometimes necessary and sometimes correct. The argument is that a cut should be one step inside a plan that has a next step, not the whole strategy. A cut with no vision behind it just resets the clock.",{"q":127,"a":128},"What does \"long-running players\" actually mean?","People who stay on a product long enough to accumulate the context of why it is built the way it is. That knowledge is the most expensive thing to rebuild and the first thing lost when you churn people to save money.",{"q":130,"a":131},"Isn't hiring only \"entrepreneurial\" people better for growth?","No. A team of only entrepreneurs invents constantly but cannot keep the lights on, and a team of only operators runs smoothly into obsolescence. You need both temperaments, and you need to value the one that is harder to measure.",{"q":133,"a":134},"How is this related to writing durable code?","Directly. Durable code and durable teams are the same bet: that the person maintaining this in three years should not need to be a hero to be safe. Cost cutting that destroys context breaks both.",{"q":136,"a":137},"Who is Frederick Vanbrabant and why reference him?","He writes about the intersection of enterprise architecture, product, and business strategy. His post Movement is not progress is what prompted this one, and this is my engineering-side companion to his argument.","8 min read",8,{"slug":141,"title":142,"description":143,"date":144,"updated":115,"tags":145,"pillar":150,"draft":17,"image":151,"imageAlt":152,"markdown":153,"faq":154,"readingTimeText":170,"readingTimeMinutes":171},"store-money-as-integer-cents-never-a-float","Store money as integer cents, never a float","An opinionated rule for money code: store amounts as integer minor units, never a binary float, and never divide a total to get its parts.","2026-07-11T00:00:00.000Z",[146,147,148,149],"payments","money","databases","best-practices","revenue-critical-code","/blog-covers/revenue-critical-code.png","Revenue-critical code cover","\n## TL;DR\n\nRepresent money as an integer count of the smallest currency unit, 1234 for $12.34, never a binary float. Floats cannot hold most decimal amounts exactly, so they leak fractions of a cent that turn into a settlement that does not reconcile.\n\nStore money as an integer count of the smallest currency unit. 1234 means $12.34. Do not use a binary float, ever, for an amount you will add, compare, or refund. This is not a style preference. It is the difference between books that reconcile and a slow leak you only find when someone audits settlement.\n\n## Why a float cannot hold $2.78\n\nIEEE 754 binary floats store numbers as sums of powers of two. Most decimal fractions, including 0.1 and 2.78, have no exact binary form, so the machine keeps the nearest approximation. The famous demonstration is one line in almost every language:\n\n```js\n0.1 + 0.2            // 0.30000000000000004\n0.1 + 0.2 === 0.3    // false\n```\n\nPHP, Python, and Java doubles do the same thing. The value looks right when you print it with two decimals, which is exactly why this bug survives to production. The moment you add, multiply, or compare these amounts, the tiny error compounds, and as [Modern Treasury explains in their writeup on why floats do not work for cents](https://www.moderntreasury.com/journal/floats-dont-work-for-storing-cents), every workaround (round to two places, compare within an epsilon) is a patch on a representation that was wrong for money from the start.\n\n## Integer minor units, the boring correct default\n\nStore the amount as a whole number of the smallest unit the currency has. $12.34 is 1234. This is what [Stripe](https://docs.stripe.com/currencies) does: every amount in its API is a positive integer in the smallest currency unit, so 1000 charges ten dollars, and 10 charges ten yen, because JPY is a zero-decimal currency with no minor unit. Integers add, subtract, and compare exactly. A database `BIGINT` is cheap, unambiguous, and immune to the epsilon dance.\n\nThe single discipline this asks of you: format to a decimal string only at the very edge, when you render to a human or hand the amount to a gateway. Everywhere in between, it is an integer.\n\n## The rule that actually saves you: never divide a total\n\nThe float question gets the attention, but the bug I see more often is dividing a total to get its parts. Split $10.00 three ways by dividing and you get 333 plus 333 plus 333, which is 999, and a cent has vanished into the floor function. I walked through this exact failure in a payments context in [six payments edge cases that bite at 2am](/blog/six-payments-edge-cases-that-bite-at-2am).\n\nThe fix is to allocate, not divide: give each part the floor, then hand out the remainder one unit at a time until it is gone. Martin Fowler named this decades ago as the [Money pattern's `allocate()`](https://martinfowler.com/eaaCatalog/money.html), which exists precisely so you avoid losing pennies to rounding.\n\n```php\nfunction allocate(int $totalCents, int $parts): array {\n    $base = intdiv($totalCents, $parts);\n    $remainder = $totalCents - $base * $parts;\n    $result = array_fill(0, $parts, $base);\n    for ($i = 0; $i \u003C $remainder; $i++) {\n        $result[$i]++;   // distribute the leftover, one cent at a time\n    }\n    return $result;      // 1000 over 3 -> [334, 333, 333], sums back to 1000\n}\n```\n\nThis is the same discipline that keeps a refund honest and keeps a mid-cycle plan change honest. It is why I read the refund path first when I audit a codebase, in [the refund path is where I read a codebase's soul](/blog/the-refund-path-is-where-i-read-a-codebases-soul), and why proration is where subscription systems quietly leak, in [subscriptions are just licensing that bills on time](/blog/subscriptions-are-licensing-that-bills-on-time).\n\n## The honest counterargument\n\nThere is a reasonable objection, argued well in [Storing money as integer cents is often over-engineering](https://world.hey.com/otar/storing-money-as-integer-cents-is-often-over-engineering-7238a485): integer cents can be more ceremony than a small app needs, and an exact `DECIMAL` column with a money library does the job. I agree with half of it. A SQL `DECIMAL` (or `NUMERIC`) is exact, fixed-point, base-10 arithmetic, not a float, and [every serious database has had it for decades](https://cardinalby.github.io/blog/post/best-practices/storing-currency-values-data-types/). If your stack has a first-class decimal type and you use it consistently, you will not leak money.\n\nWhat I will not accept is a binary float, and I will not accept deriving a part by dividing a whole. So pick one exact representation, integer minor units or a real decimal type, commit to it, and never mix the two in the same column.\n\n## What this looks like in a schema\n\n```sql\n-- good: exact, unambiguous, integer minor units\namount_cents  BIGINT        NOT NULL,   -- 1234 = $12.34\ncurrency      CHAR(3)       NOT NULL,   -- 'USD'\n\n-- also fine, if you commit to it everywhere\namount        NUMERIC(14, 2) NOT NULL,\n\n-- never\namount        DOUBLE PRECISION           -- a slow leak with a two-decimal disguise\n```\n\nStore the currency next to the amount. An amount without its currency is Fowler's other warning: sooner or later you add dollars to yen and nobody notices until the total is wrong.\n\n## The takeaway\n\nMoney is a count, not a measurement, so represent it as an integer of the smallest unit, keep the currency beside it, and never derive a part by dividing a whole. Do that and your refunds, proration, and reconciliation stay honest. Skip it and you ship a leak that hides until the numbers stop matching at settlement, which is the most expensive place to find it.\n\n## FAQ\n\n**Is a SQL DECIMAL just a float under the hood?**\nNo. `DECIMAL` and `NUMERIC` are exact fixed-point base-10 types, while `FLOAT` and `DOUBLE PRECISION` are binary approximations. `DECIMAL` is a safe way to store money; a binary float is not.\n\n**Why does Stripe send 1000 for ten dollars?**\nBecause it represents every amount in the currency's smallest unit. Ten dollars is 1000 cents; ten yen is 10, because JPY is a zero-decimal currency with no minor unit.\n\n**What about currencies with three decimal places, like the Kuwaiti dinar?**\nStore the correct number of minor units for that currency, which is 1000 per dinar, not 100. Do not hard-code two decimals; look up the currency's exponent.\n\n**Can I just round at the end and move on?**\nRounding at the end does not undo a float that was already wrong at every intermediate step, and it silently drops remainders. Allocate the remainder explicitly instead of letting a division swallow it.\n\n**Integer cents or a money library, which should I pick?**\nEither, as long as you are consistent. A first-class exact decimal type wrapped in a money object is fine, and integer minor units are fine. Mixing them, or reaching for a binary float, is what gets you.\n",[155,158,161,164,167],{"q":156,"a":157},"Is a SQL DECIMAL just a float under the hood?","No. DECIMAL and NUMERIC are exact fixed-point base-10 types, while FLOAT and DOUBLE PRECISION are binary approximations. DECIMAL is a safe way to store money; a binary float is not.",{"q":159,"a":160},"Why does Stripe send 1000 for ten dollars?","Because it represents every amount in the currency's smallest unit. Ten dollars is 1000 cents; ten yen is 10, because JPY is a zero-decimal currency with no minor unit.",{"q":162,"a":163},"What about currencies with three decimal places, like the Kuwaiti dinar?","Store the correct number of minor units for that currency, which is 1000 per dinar, not 100. Do not hard-code two decimals; look up the currency's exponent.",{"q":165,"a":166},"Can I just round at the end and move on?","Rounding at the end does not undo a float that was already wrong at every intermediate step, and it silently drops remainders. Allocate the remainder explicitly instead of letting a division swallow it.",{"q":168,"a":169},"Integer cents or a money library, which should I pick?","Either, as long as you are consistent. A first-class exact decimal type wrapped in a money object is fine, and integer minor units are fine. Mixing them, or reaching for a binary float, is what gets you.","6 min read",6,{"slug":173,"title":174,"description":175,"date":176,"updated":177,"tags":178,"pillar":150,"draft":17,"image":151,"imageAlt":152,"markdown":180,"faq":181,"readingTimeText":200,"readingTimeMinutes":201},"the-refund-path-is-where-i-read-a-codebases-soul","The refund path is where I read a codebase's soul","Checkout code is easy. Refunds, proration, and failed webhooks are where I can tell if a payments codebase was built by someone senior.","2026-07-08T00:00:00.000Z","2026-07-27T00:00:00.000Z",[146,83,53,12,179],"webhooks","\n## TL;DR\n\nThe refund path, not checkout, reveals whether a payments codebase was written by someone senior. The five tells: refunds as append-only events, integer line-item math, database-enforced idempotency, gateway-confirmed state, and entitlement that revokes on the confirmed refund.\n\nWhen I audit a payments codebase, I skip the checkout flow and go straight to the refund path. Checkout is the demo. Every tutorial gets money in. The senior work shows up where money has to go back out: partial refunds, proration on a mid-cycle change, and the webhook that confirms none of it. Those branches are unglamorous, rarely demoed, and almost never covered by tests, which is exactly why they tell the truth about who wrote the code.\n\nI have shipped this path more than once: on a WooCommerce marketplace, inside a WordPress subscription and licensing plugin, and around a Laravel mail service that had to bill for what it actually sent. The same tells appear every time. Here is how I read them.\n\n## Tell one: is a refund an event, or a mutation?\n\nOpen the refund handler and look at what it writes. If it does something like this, I already know the maturity level:\n\n```php\n// The junior version\n$order->status = 'refunded';\n$order->save();\n```\n\nThat flips a boolean and throws away the truth. A refund is not a state, it is an event with an amount, a reason, a gateway reference, and a time. The senior version appends to a ledger and never overwrites history:\n\n```php\n$refund = Refund::create([\n    'order_id'        => $order->id,\n    'amount_cents'    => $amountCents,\n    'currency'        => $order->currency,\n    'gateway_ref'     => null,          // filled when the gateway confirms\n    'idempotency_key' => $idempotencyKey,\n    'reason'          => $reason,\n    'status'          => 'pending',\n    'created_at'      => now(),\n]);\n```\n\nOnce refunds are rows, the hard questions answer themselves. Total refunded is `sum(amount_cents)` scoped to the order, not a flag. A second partial refund is just another row. Reconciliation against the gateway becomes a join instead of a guess. The moment I see the order row being mutated in place, I know reporting is going to lie to this business by month three.\n\n## Tell two: partial refunds, and the off-by-a-cent that becomes a support ticket\n\nThe partial refund is where I find the rounding bugs. Say a customer bought three seats at a bundle price and wants one seat back. Junior code divides the order total by three. On a price that does not divide cleanly, you now refund a cent too much or too little, and across enough orders your gateway settlement stops matching your ledger.\n\nThe fix is to never derive a refund by dividing a total. Refund against recorded line items, in [integer cents](/blog/store-money-as-integer-cents-never-a-float), and let the last unit absorb the remainder:\n\n```php\npublic function refundableCents(OrderItem $item, int $qty): int\n{\n    // unit_price_cents is stored per item at purchase time, in integer cents\n    $alreadyRefunded = $item->refunds()->sum('quantity');\n    $remaining = $item->quantity - $alreadyRefunded;\n\n    if ($qty > $remaining) {\n        throw new RefundExceedsRemainingException($item->id, $remaining);\n    }\n\n    return $item->unit_price_cents * $qty;\n}\n```\n\nNotice the guard. If the requested quantity exceeds what is left to refund, it throws instead of quietly returning a negative or over-refunding. The gateway itself will not let you: [Stripe allows several refunds against one charge but never a total greater than the original amount](https://docs.stripe.com/refunds). Code that lets you refund more than was paid is code that has never met a determined customer or a scripted retry.\n\nThe rule I hold is blunt: a refund path that can ever pay back more than was collected is a defect, not an edge case. I would rather throw loudly on an impossible quantity than let a fraction of a cent drift between my ledger and the gateway's settlement, because that drift stays invisible until reconciliation and is miserable to unwind after the fact.\n\n## Tell three: idempotency, because the network will double-fire\n\nThe single clearest senior signal is an [idempotency key](https://docs.stripe.com/api/idempotent_requests) on the refund. Refunds get triggered by humans clicking twice, by admin dashboards timing out and retrying, and by webhook redelivery. These are the same duplicate-delivery and retry problems that [bite every payment integration at 2am](/blog/six-payments-edge-cases-that-bite-at-2am). If the same logical refund can run twice, you will eventually pay a customer back twice, and clawing that money back is a worse conversation than the original complaint.\n\nI make the key part of the request, not something generated inside the handler, and I enforce it at the database:\n\n```sql\nCREATE UNIQUE INDEX ux_refunds_idem\n    ON refunds (order_id, idempotency_key);\n```\n\n```php\ntry {\n    $refund = $this->createRefund($order, $amountCents, $idempotencyKey);\n} catch (UniqueConstraintViolationException) {\n    // Already processed this exact refund. Return the existing one, do not create a second.\n    return Refund::where('order_id', $order->id)\n        ->where('idempotency_key', $idempotencyKey)\n        ->firstOrFail();\n}\n```\n\nThe database is the referee. Application-level \"check then insert\" loses to a race under real concurrency, and refunds are exactly the place races bite, because retries cluster in time. If the codebase relies on a `SELECT` before the `INSERT` with no unique constraint behind it, I write that down as a defect, not a style preference.\n\n## Tell four: the local state is optimistic, the gateway is the truth\n\nHere is the branch most codebases get wrong. You call the gateway, it returns 200, you mark the refund complete. But a refund at Stripe or PayPal or Paddle is not final on the API response, it is final when the gateway's webhook says so. The API can accept a refund that later fails at the card network. If your local state jumped straight to `completed`, your ledger now disagrees with reality and nobody notices until settlement.\n\nSo the refund has two stages. The API call moves it to `processing`. The webhook moves it to `completed` or `failed`:\n\n```php\npublic function handleRefundWebhook(array $payload): void\n{\n    $refund = Refund::where('gateway_ref', $payload['refund_id'])->first();\n    if (! $refund) {\n        // Out-of-order delivery: the webhook beat our own record. Park it, do not drop it.\n        return $this->parkForReplay($payload);\n    }\n\n    // Webhooks arrive out of order and more than once. Only move forward.\n    if ($refund->status === 'completed') {\n        return;\n    }\n\n    $refund->update([\n        'status'       => $payload['status'] === 'succeeded' ? 'completed' : 'failed',\n        'confirmed_at' => now(),\n    ]);\n\n    if ($refund->status === 'completed') {\n        $this->revokeEntitlement($refund);\n    }\n}\n```\n\nTwo details in there are the whole game. The early return when the webhook arrives before the local record exists (out-of-order delivery is normal, not exotic), and the guard against moving a `completed` refund again on redelivery. Providers [redeliver events on their own retry schedule and make no ordering promise](https://docs.stripe.com/webhooks), so a codebase that treats webhooks as reliable, ordered, and once-only is a codebase that has not run in production long enough.\n\n## Tell five: does the refund touch entitlement?\n\nThis is where payments meets the rest of the product, and where I connect the refund path to the [licensing and subscription work I do most](/blog/subscriptions-are-licensing-that-bills-on-time). A refund that moves money but leaves the customer with a working license key, an active subscription, or a downloadable they already refunded is a leak. Revocation belongs on the confirmed refund, not the API call, because you do not want to lock someone out of a refund that later fails at the network and never actually settles.\n\nSo `revokeEntitlement` runs off the webhook, and it is careful about partial refunds. Refunding one of three seats should drop the seat count, not kill the whole license. If the refund handler cannot tell the difference between \"gave back everything\" and \"gave back one unit,\" entitlement will either over-punish paying customers or leak access to refunded ones. Both cost money, just on different lines of the spreadsheet.\n\n## Why this path, specifically\n\nI do not read the refund path because refunds are common. On a healthy product they are not. I read it because it is the one flow where the code has to be correct while money moves the wrong direction, under retries, with an async source of truth, and with a customer who is already annoyed. Nobody polishes that branch to look good in a demo. That is precisely why it is honest.\n\nAll of that is about how a refund executes. Who decides to issue one is a different question, and while you are still learning from your customers I argue [you should not automate that decision at all](/blog/when-to-automate-refunds).\n\n## The takeaway\n\nThe refund path is the one flow that has to be correct while money moves the wrong way, under retries, against an async source of truth, for a customer who is already unhappy. Nobody polishes it for a demo, so it reports the real maturity of the code: event over mutation, integer line items over division, a database-enforced idempotency key, gateway-confirmed state, and entitlement that revokes on the confirmed refund. Read those five tells and you know what a codebase actually is, not what it wants to be.\n\nShow me your checkout and I learn what your product wants to be. Show me your refund path and I learn what it actually is.\n\n## FAQ\n\n**Why read the refund path instead of the checkout flow?**\nCheckout is the demo path every tutorial covers, so it says little about seniority. Refunds, proration, and webhook confirmation are rarely demoed and rarely tested, which is exactly why they reveal who wrote the code.\n\n**Should a refund be stored as a status change or a new record?**\nA new record. A refund is an event with an amount, a reason, a gateway reference, and a time, so appending a row keeps history intact, makes total refunded a sum, and turns reconciliation into a join instead of a guess.\n\n**How do I stop partial refunds from drifting by a cent?**\nNever derive a refund by dividing the order total. Refund against recorded line items in integer cents, let the last unit absorb any remainder, and guard so a request cannot exceed what is left to refund.\n\n**Where should the idempotency key be enforced?**\nAt the database, with a unique index on the order and idempotency key. An application-level check-then-insert loses to the races that cluster around retries, and refunds are exactly where those races bite.\n\n**When is a refund actually final?**\nWhen the gateway's webhook confirms it, not when the API returns 200. Move the refund to processing on the API call and to completed or failed on the webhook, and only revoke entitlement once it is confirmed.\n\n**Should refunding one of three seats revoke the whole license?**\nNo. A partial refund should drop the affected quantity, not kill the entire entitlement, or you either over-punish paying customers or leak access to refunded ones.\n",[182,185,188,191,194,197],{"q":183,"a":184},"Why read the refund path instead of the checkout flow?","Checkout is the demo path every tutorial covers, so it says little about seniority. Refunds, proration, and webhook confirmation are rarely demoed and rarely tested, which is exactly why they reveal who wrote the code.",{"q":186,"a":187},"Should a refund be stored as a status change or a new record?","A new record. A refund is an event with an amount, a reason, a gateway reference, and a time, so appending a row keeps history intact, makes total refunded a sum, and turns reconciliation into a join instead of a guess.",{"q":189,"a":190},"How do I stop partial refunds from drifting by a cent?","Never derive a refund by dividing the order total. Refund against recorded line items in integer cents, let the last unit absorb any remainder, and guard so a request cannot exceed what is left to refund.",{"q":192,"a":193},"Where should the idempotency key be enforced?","At the database, with a unique index on the order and idempotency key. An application-level check-then-insert loses to the races that cluster around retries, and refunds are exactly where those races bite.",{"q":195,"a":196},"When is a refund actually final?","When the gateway's webhook confirms it, not when the API returns 200. Move the refund to processing on the API call and to completed or failed on the webhook, and only revoke entitlement once it is confirmed.",{"q":198,"a":199},"Should refunding one of three seats revoke the whole license?","No. A partial refund should drop the affected quantity, not kill the entire entitlement, or you either over-punish paying customers or leak access to refunded ones.","9 min read",9,{"slug":203,"title":204,"description":205,"date":206,"updated":177,"tags":207,"pillar":211,"draft":17,"image":212,"imageAlt":213,"markdown":214,"faq":215,"readingTimeText":231,"readingTimeMinutes":232},"you-dont-rewrite-a-plugin-with-a-big-install-base","You don't rewrite a plugin with a big install base","How I evolve a WordPress plugin with a large install base without breaking users mid-update: a strangler-fig approach with real PHP and version gates.","2026-07-01T00:00:00.000Z",[54,208,209,210,52],"plugins","refactoring","migrations","plugin-modernization","/blog-covers/plugin-modernization.png","Plugin modernization cover","\n## TL;DR\n\nDo not rewrite a plugin with a large install base. Evolve it live with the strangler fig pattern: freeze public names, gate new internals behind per-site options, dual-write before you dual-read, and delete legacy code only once it is provably dead.\n\nThe big rewrite is the most expensive decision you can make on a plugin that people already run in production. I have maintained affiliate, licensing, and link-management plugins with install bases large enough that any given hour, someone somewhere is mid-checkout when my update lands. So I don't rewrite. I operate on the patient while they are awake, one organ at a time, and I never cut anything that is still load-bearing.\n\nThe pattern has a name: [strangler fig](https://martinfowler.com/bliki/StranglerFigApplication.html). You wrap the old code, route a slice of behavior through new code, prove it, then let the new growth quietly replace the old. Nothing goes dark. Here is how that actually looks in WordPress, where you don't control the host, [the PHP version](/blog/supporting-a-plugin-across-wordpress-versions), or when the user clicks update.\n\n## Keep the public surface frozen\n\nThe first rule is that the function and hook names other people depend on do not move. Site owners have snippets in `functions.php`. Other plugins hook your filters. If you rename `affapp_get_referral()` because the new architecture prefers a repository, you just broke a thousand child themes you have never seen.\n\nSo the public function stays. Its guts get a switch.\n\n```php\nfunction affapp_get_referral( $referral_id ) {\n    if ( affapp_referrals_v2_enabled() ) {\n        return \\AffApp\\Referrals\\Repository::find( (int) $referral_id );\n    }\n\n    return affapp_legacy_get_referral( $referral_id );\n}\n\nfunction affapp_referrals_v2_enabled() {\n    // Off by default. Flipped per-site, never in the plugin file.\n    return (bool) get_option( 'affapp_referrals_v2', 0 );\n}\n```\n\nThe legacy function does not get deleted. It gets renamed to `affapp_legacy_get_referral()` and kept as the fallback. The facade is the only thing the outside world sees, and it looks identical to what it saw last release. That is the whole trick: change everything behind a surface that changed nothing.\n\n## Route a slice, not the whole thing\n\nYou do not flip the new path on for the world in one release. You flip it for one path, on one site, and watch. The option gate above is per-site, the same lever I lean on to [ship a feature in staged percentages](/blog/shipping-a-feature-into-a-five-year-old-plugin), which means I can turn on the v2 referrals repository on my own staging install, then on a friendly customer's site, before it is anywhere near the default.\n\nThe staging is important because production data is never as clean as your schema thinks. The legacy referrals table on a five-year-old site has rows with `NULL` in a column that your new code assumes is always an integer, or a `status` string that predates an enum you introduced three years ago. New code that has only ever seen fresh installs will trip on that. Routing a narrow slice through real, old data is how you find the rows your migration forgot.\n\n## Dual-write before you dual-read\n\nWhen the new code owns a different shape of data, do not migrate everything and swap. Write to both, read from one, and let the two converge. For the licensing plugin, moving activation records into a normalized table meant every activation had to land in the old table and the new one during the transition window.\n\n```php\nadd_action( 'affapp_license_activated', function ( $license_id, $site_url ) {\n    // Legacy path stays authoritative until v2 is proven.\n    affapp_legacy_record_activation( $license_id, $site_url );\n\n    if ( affapp_activations_v2_enabled() ) {\n        \\AffApp\\Activations\\Repository::record( $license_id, $site_url );\n    }\n}, 10, 2 );\n```\n\nReads still come from the legacy table. The new table is being populated in parallel so that when I do flip reads, it already holds live data, not a stale one-time export. When the two tables agree for long enough, and I have a script that diffs them, reads move over. The old table stays for one more release as a rollback net, then gets retired.\n\n## The version gate is the seatbelt\n\nEvery schema touch runs once, guarded by a stored version. I do not trust the plugin version string for this, because a user can downgrade, re-upgrade, or restore a backup. I keep a separate schema version in the options table and compare against it on [`plugins_loaded`](https://developer.wordpress.org/reference/hooks/plugins_loaded/), following the same [backward-compatible migration rules](/blog/backward-compatible-migrations-on-an-install-base) I hold everywhere.\n\n```php\nconst AFFAPP_DB_VERSION = 7;\n\nadd_action( 'plugins_loaded', 'affapp_maybe_upgrade_schema' );\n\nfunction affapp_maybe_upgrade_schema() {\n    $installed = (int) get_option( 'affapp_db_version', 0 );\n    if ( $installed >= AFFAPP_DB_VERSION ) {\n        return;\n    }\n\n    require_once __DIR__ . '/includes/schema.php';\n\n    if ( $installed \u003C 7 ) {\n        affapp_schema_7_add_activation_table();\n    }\n\n    update_option( 'affapp_db_version', AFFAPP_DB_VERSION );\n}\n```\n\nEach step is additive and idempotent. `affapp_schema_7_add_activation_table()` uses [`dbDelta()`](https://developer.wordpress.org/reference/functions/dbdelta/), which will create the table if it is missing and leave it alone if it already matches. That means a user who half-upgraded, hit a timeout, and reloaded does not get a broken half-migration. The step either completes or is safe to run again.\n\n```php\nfunction affapp_schema_7_add_activation_table() {\n    global $wpdb;\n    $table   = $wpdb->prefix . 'affapp_activations';\n    $charset = $wpdb->get_charset_collate();\n\n    $sql = \"CREATE TABLE {$table} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        license_id BIGINT UNSIGNED NOT NULL,\n        site_url VARCHAR(255) NOT NULL DEFAULT '',\n        activated_at DATETIME NOT NULL,\n        PRIMARY KEY  (id),\n        KEY license_id (license_id)\n    ) {$charset};\";\n\n    require_once ABSPATH . 'wp-admin/includes/upgrade.php';\n    dbDelta( $sql );\n}\n```\n\nNote that I only ever add. No `DROP`, no `ALTER ... DROP COLUMN`, no destructive rename in an upgrade path a user cannot roll back from. If the new code is wrong, I flip the option off and the old table is still there, untouched, still authoritative.\n\n## Let the old growth die on its own schedule\n\nThe strangler fig does not kill the host tree in one cut. It grows around it until the host is no longer holding anything up, and then the host is just deadwood you can remove at leisure. Same with the legacy path. Once v2 reads have been default-on across the install base for a couple of releases with no rollbacks, the legacy function has no callers left. That is when it goes, in a release whose notes say \"internal cleanup\" because from the outside nothing changed.\n\n## The takeaway\n\nThe discipline is boring on purpose. Freeze the surface, gate the internals, dual-write, version the schema, keep every step additive, and delete only what is provably dead. You will move slower than a rewrite promises to. You will also never wake up to a support queue full of people whose store stopped taking orders because your clean new architecture met their five-year-old data at the wrong moment.\n\nA rewrite bets the install base on your ability to predict every edge case in one shot. Operating awake bets on nothing. That is the point.\n\n## FAQ\n\n**Should I ever rewrite a plugin from scratch?**\nAlmost never once it has a real install base. A rewrite bets everything on predicting every edge case in one shot, while operating awake with a strangler fig lets you prove each change on live data before it becomes the default.\n\n**What is the strangler fig pattern in a WordPress plugin?**\nYou wrap the old code, route a narrow slice of behavior through new code behind a gate, prove it against real data, then let the new path replace the old once it has no callers left. Nothing goes dark during the transition.\n\n**Why keep the legacy function instead of deleting it after adding the new path?**\nBecause it is your fallback and your rollback net. The old function gets renamed to something like affapp_legacy_get_referral() and stays behind the frozen public facade, so if the new path is wrong you flip one option off and the old behavior is still there.\n\n**How do I turn on the new path safely?**\nUse a per-site option that is off by default and never flipped in the plugin file. Enable it on your own staging install first, then a friendly customer, watching how the new code meets old, messy production data before it goes anywhere near the default.\n\n**When is it safe to delete the old code?**\nOnce v2 reads have been default-on across the install base for a couple of releases with no rollbacks, the legacy path has no callers left. Only then does it go, in a release whose notes read \"internal cleanup\" because from the outside nothing changed.\n",[216,219,222,225,228],{"q":217,"a":218},"Should I ever rewrite a plugin from scratch?","Almost never once it has a real install base. A rewrite bets everything on predicting every edge case in one shot, while operating awake with a strangler fig lets you prove each change on live data before it becomes the default.",{"q":220,"a":221},"What is the strangler fig pattern in a WordPress plugin?","You wrap the old code, route a narrow slice of behavior through new code behind a gate, prove it against real data, then let the new path replace the old once it has no callers left. Nothing goes dark during the transition.",{"q":223,"a":224},"Why keep the legacy function instead of deleting it after adding the new path?","Because it is your fallback and your rollback net. The old function gets renamed to something like affapp_legacy_get_referral() and stays behind the frozen public facade, so if the new path is wrong you flip one option off and the old behavior is still there.",{"q":226,"a":227},"How do I turn on the new path safely?","Use a per-site option that is off by default and never flipped in the plugin file. Enable it on your own staging install first, then a friendly customer, watching how the new code meets old, messy production data before it goes anywhere near the default.",{"q":229,"a":230},"When is it safe to delete the old code?","Once v2 reads have been default-on across the install base for a couple of releases with no rollbacks, the legacy path has no callers left. Only then does it go, in a release whose notes read \"internal cleanup\" because from the outside nothing changed.","7 min read",7,{"slug":234,"title":235,"description":236,"date":237,"updated":177,"tags":238,"pillar":85,"draft":17,"image":86,"imageAlt":87,"markdown":240,"faq":241,"readingTimeText":138,"readingTimeMinutes":139},"the-first-hire-who-touches-your-billing-should-be-boring","The first hire who touches your billing should be boring","The engineer who owns revenue early should reach for idempotency, audit logs, and reconciliation by reflex. How to screen for it and reject the rest.","2026-06-24T00:00:00.000Z",[14,239,85],"hiring","\n## TL;DR\n\nHire the engineer who reaches for idempotency, audit logs, and reconciliation by reflex before you ask. Screen with real scenarios like a double charge or a silent webhook failure, not raw velocity, which is the wrong first filter for billing.\n\nThe engineer who owns revenue early should be the least exciting person in the room. You want someone whose first question about a payment webhook is \"what happens if this fires twice\", not \"can we rewrite this in the new hotness\". Boring here is a compliment. It means they have been burned before and they build like it.\n\nI have shipped billing and licensing for WordPress plugins and Laravel apps for years. The pattern that separates the people you want from the people who will cost you a weekend of refunds is not raw intelligence. It is what they reach for by reflex when money is on the line, the same reflexes I [insist on before writing any billing code](/blog/three-questions-before-i-write-billing-code).\n\n## What boring actually looks like\n\nBoring is a set of habits, not a personality. When I interview or review someone for a revenue-critical role, I am listening for these reflexes:\n\n- They ask about idempotency before I bring it up.\n- They assume every external call can fail, retry, or arrive out of order.\n- They want an audit log of state changes before they want a dashboard.\n- They treat the payment provider as the source of truth and reconcile against it.\n- They know the difference between \"the charge succeeded\" and \"our database believes the charge succeeded\".\n\nNone of that is clever. All of it is the difference between a quiet Monday and a support queue full of people charged twice. Worth saying plainly: this is a role, not a verdict on who is valuable. A team needs [both operators and entrepreneurial temperaments](/blog/cost-cutting-is-motion-not-a-strategy), and the argument here is only about who should own the code that moves money.\n\n## Danger signal one: no idempotency\n\nAsk a candidate how they would handle a Stripe webhook that Stripe retries three times because your server was slow to return 200. If the answer is \"we would only get it once\", pass. Providers [retry by design](https://docs.stripe.com/webhooks), and duplicate delivery is one of the [payment edge cases that bite at 2am](/blog/six-payments-edge-cases-that-bite-at-2am). Your handler has to be safe to run more than once with the same event.\n\nThe boring version is unglamorous:\n\n```php\npublic function handle(WebhookEvent $event): void\n{\n    // Stripe (and every serious provider) can deliver the same event\n    // more than once. The unique index on provider_event_id is the guard.\n    $seen = ProcessedEvent::where('provider_event_id', $event->id)->exists();\n    if ($seen) {\n        return; // safe no-op, we have handled this one already\n    }\n\n    DB::transaction(function () use ($event) {\n        ProcessedEvent::create(['provider_event_id' => $event->id]);\n        $this->applyToSubscription($event);\n    });\n}\n```\n\nThe [unique index](https://www.postgresql.org/docs/current/indexes-unique.html) does the real work. If two retries race, the second insert throws on the constraint and the transaction rolls back. No double credit, no double email. A candidate who reaches for the database constraint instead of an in-memory check has done this before.\n\n## Danger signal two: no audit log\n\nIf state changes to a subscription or a license leave no trail, you are one confused customer away from a debate you cannot win. \"You cancelled on the 3rd.\" \"No I didn't.\" Without an append-only record, that conversation ends in a goodwill refund every time.\n\nI want a table that answers \"what changed, when, triggered by what, and what was the value before and after\". Not a mutable status column you overwrite. An event stream you append to. When a founder tells me support takes hours because nobody can reconstruct what happened to an account, the missing piece is almost always this.\n\nThe person who builds the audit log before anyone asks for it is the person you want on billing.\n\n## Danger signal three: no reconciliation\n\nYour database and your payment provider will drift. It does not matter whether you [bought a hosted processor or built the billing layer yourself](/blog/build-vs-buy-for-billing): a webhook gets dropped, a deploy eats an event, a network blip lands mid transaction. Over a month, small drift becomes real money and real angry customers. The boring engineer expects this and builds a reconciliation job that compares your records against the provider's records on a schedule and flags the gaps.\n\nI do not trust any billing system that has no answer to \"how do you know your database matches Stripe right now\". If the answer is \"it should\", that is a system waiting to be wrong quietly. Reconciliation is the smoke detector. It is cheap, it runs nightly, and the first time it catches a dropped webhook it pays for itself.\n\n## Danger signal four: \"we'll test in prod\"\n\nThere is a healthy version of shipping fast and there is the version where someone runs an untested refund path against live customer cards because setting up the provider's test mode felt slow. On billing, the second one is disqualifying. [Test mode exists](https://docs.stripe.com/testing), with published card numbers for every decline you want to rehearse. Sandbox keys exist. Replaying real webhook payloads against a staging handler is a ten minute setup.\n\nThe tell is not that they move fast. Good billing engineers move fast. The tell is what they are willing to be careless about. Careless about a CSS regression, fine. Careless about the code that moves money, no.\n\n## Velocity is not the metric here\n\nFounders hire for velocity because everything else in an early product rewards it. Billing is the one area where velocity is the wrong first filter. A fast engineer who ships a charge path with no idempotency and no audit log has not saved you time. They have moved the cost to a future month, with interest, and added your customers' trust to the bill.\n\nThe good news is that boring is teachable and testable. You do not need to find a genius. You need to find someone who, when you say the word \"webhook\", immediately says \"we need to make that idempotent\". Ask three or four questions like the ones above. The right person answers them before you finish asking.\n\n## How to actually screen for it\n\nSkip the abstract system design puzzle. Hand them a real scenario:\n\n1. A customer says they were charged twice. Walk me through how you would confirm it and fix it.\n2. A webhook handler failed silently last night. How would you have known?\n3. We need to give annual customers a refund for the unused months. How do you build that safely?\n\nYou are not grading the final answer. You are listening for whether reconciliation, audit logs, and idempotency show up on their own. If they do, you have found your boring hire. Hold onto them. The exciting hires will thank them later, usually right after an incident.\n\nThe engineer who makes billing boring is doing the most valuable thing an early engineer can do: making sure the part of your product that pays for everything else never becomes the story.\n\n## The takeaway\n\nBoring is not a lack of talent, it is a set of reflexes you can screen for in an afternoon. The engineer who makes idempotency, audit logs, and reconciliation non-negotiable is protecting the part of your product that pays for everything else. Hire for that, not for the flashiest line of code, and billing stops being the thing that ruins your weekends.\n\n## FAQ\n\n**Is a boring billing engineer just a less ambitious hire?**\nNo. Boring here means disciplined about money code, not slow or unskilled. Good billing engineers still move fast, they just refuse to be careless about idempotency, audit logs, and reconciliation.\n\n**What is the single fastest way to screen for this in an interview?**\nAsk how they would handle a webhook that fires three times. If they reach for a database unique constraint instead of an in-memory check, they have done this before.\n\n**Can I train an existing generalist into this role instead of hiring for it?**\nYes, these habits are teachable and testable. You are looking for someone who says \"make that idempotent\" when they hear \"webhook\", and that reflex can be coached.\n\n**Why not just optimize for velocity like every other early hire?**\nBecause on billing, velocity without idempotency and audit logs moves the cost to a future month with interest, plus your customers' trust. It is the one area where speed is the wrong first filter.\n\n**What if we cannot afford a dedicated billing hire yet?**\nThen make the three habits a review requirement for whoever ships revenue code. The reflexes matter more than the title.\n",[242,245,248,251,254],{"q":243,"a":244},"Is a boring billing engineer just a less ambitious hire?","No. Boring here means disciplined about money code, not slow or unskilled. Good billing engineers still move fast, they just refuse to be careless about idempotency, audit logs, and reconciliation.",{"q":246,"a":247},"What is the single fastest way to screen for this in an interview?","Ask how they would handle a webhook that fires three times. If they reach for a database unique constraint instead of an in-memory check, they have done this before.",{"q":249,"a":250},"Can I train an existing generalist into this role instead of hiring for it?","Yes, these habits are teachable and testable. You are looking for someone who says \"make that idempotent\" when they hear \"webhook\", and that reflex can be coached.",{"q":252,"a":253},"Why not just optimize for velocity like every other early hire?","Because on billing, velocity without idempotency and audit logs moves the cost to a future month with interest, plus your customers' trust. It is the one area where speed is the wrong first filter.",{"q":255,"a":256},"What if we cannot afford a dedicated billing hire yet?","Then make the three habits a review requirement for whoever ships revenue code. The reflexes matter more than the title.",{"slug":258,"title":259,"description":260,"date":261,"updated":177,"tags":262,"pillar":16,"draft":17,"image":18,"imageAlt":19,"markdown":263,"faq":264,"readingTimeText":138,"readingTimeMinutes":139},"boring-code-is-a-feature-you-ship-to-your-future-self","Boring code is a feature you ship to your future self","Boring code wins on systems that have to last. Why the durable choice looks unimpressive, how to defend it in review, and when to delete clever code.","2026-06-17T00:00:00.000Z",[13,15,12,16],"\n## TL;DR\n\nThe durable choice in code usually looks unimpressive by design. Boring code optimizes for the incident and the handoff, so defend the plain version from the failure mode, not taste, and delete clever code when a simpler version exists.\n\nThe durable choice in code almost always looks unimpressive, and that is the feature, not a compromise. Boring code (the obvious control flow, the explicit conditional, the named variable instead of the clever chain) is something you ship to the person who has to debug it at 2am, ideally warned by [monitoring that runs at the edge](/blog/why-i-run-uptime-checks-at-the-edge) before users are, and that person is usually you. Cleverness feels like value while you write it. It reveals its true cost six months later, when someone reads it under pressure and cannot tell what it does.\n\nI build revenue-critical systems: licensing, subscriptions, payments, where the boring choices, like [storing money as integer cents rather than a float](/blog/store-money-as-integer-cents-never-a-float), are the ones that keep the numbers honest. In that world an outage is not a slow page, it is customers who cannot pay or cannot use what they paid for. That raises the stakes on readability in a way that reframes the whole \"elegant vs boring\" argument. When the failure mode is money and trust, the most valuable property of code is that a tired human can understand it correctly on the first read.\n\n## Why boring wins\n\nBoring code optimizes for the moment that actually matters: the incident, the handoff, the change you make a year after you forgot how it worked. Clever code optimizes for the moment that does not matter much: the fifteen minutes you spent writing it feeling smart.\n\nConcretely, boring means:\n\n- One obvious path through a function, not three clever branches folded into a ternary.\n- Explicit names for the states of a thing, not booleans you have to decode.\n- Doing the dumb reliable thing (a [database unique constraint](https://www.postgresql.org/docs/current/ddl-constraints.html)) instead of the smart fragile thing (a clever in-memory dedupe).\n- Code that a competent stranger can change without archaeology.\n\nHere is the shape of it. Same logic, two temperaments:\n\n```php\n// Clever. Reads like a puzzle, breaks like one.\nreturn $sub->trial_ends_at?->isFuture()\n    ?? ($sub->canceled_at ? false : $sub->paid);\n\n// Boring. Reads like a sentence.\nif ($sub->onTrial()) {\n    return true;\n}\nif ($sub->isCanceled()) {\n    return false;\n}\nreturn $sub->isPaid();\n```\n\nThe second is longer and I will take it every time. When a customer disputes their access at 11pm, I want to read three plain sentences, not reverse engineer a null coalescing operator I was proud of in April.\n\n## The cost clever code hides\n\nClever code moves cost in time, the same way [skipping idempotency does](/blog/six-payments-edge-cases-that-bite-at-2am). It feels free now because the author holds all the context. Martin Fowler's [technical debt metaphor](https://martinfowler.com/bliki/TechnicalDebt.html) names that trade precisely: you borrow speed today and pay interest on every change after. The context evaporates. What is left is a line that works but nobody wants to touch, so people build around it instead of through it, and the workaround becomes the next person's clever code. That is how a codebase calcifies: not from bad engineers, but from a hundred small acts of cleverness nobody could safely delete.\n\nThe durable version costs a few more keystrokes now and almost nothing later. That trade is the whole game in software that has to last.\n\n## Defending boring in code review\n\nThe hard part is social, not technical. Boring code gets challenged in review by well-meaning people who mistake terseness for quality. You need a way to defend the plain option that does not sound like you are just less skilled.\n\nWhat works for me is to argue from the reader, not the writer:\n\n- \"This is fewer lines, but count the concepts someone has to hold to be sure it is correct. The longer version has fewer.\"\n- \"Show me how you would change this under a deadline. If the answer involves rereading it three times, that is the cost.\"\n- \"What does this do when the input is null? If we both have to stop and think, the next person will get it wrong.\"\n\nNotice none of that is about taste. It is about the failure mode. Taste arguments are unwinnable. Failure-mode arguments end with someone saying \"fair\" and merging the boring version.\n\n## Deleting clever code you were proud of\n\nThe discipline nobody talks about is deletion. Sometimes the clever code is yours, it works, and you are attached to it. Delete it anyway when a plainer version exists. Sunk cost is not a code quality metric. I have replaced my own compact one liners with five boring lines more than once, usually right after watching a teammate misread the compact version in a pull request. If a smart colleague misreads it, it is not clever, it is a trap with good production values.\n\nA useful test before you keep something clever: could you delete it right now without hesitation if a simpler version existed. If the honest answer is \"no, I like it\", that attachment is the problem, not a reason to keep it.\n\n## Saying no is part of the job\n\nBoring is also a stance you take against your own tools and habits. No to the new abstraction that saves four lines and adds a concept. No to the dependency that does one thing you could write in ten lines you understand. No to the premature generalization for the second case that does not exist yet, which is [YAGNI](https://martinfowler.com/bliki/Yagni.html) pointed at your own instincts rather than someone else's roadmap. Every no keeps the surface area small, and small surface area is what lets a system survive its authors leaving.\n\nThis is not an argument for crude code or for never using a good abstraction. A well-chosen abstraction is boring in the best way: it hides complexity behind an obvious name. The line I hold is that the abstraction has to pay for the concept it adds. Most clever code does not pass that test. It adds a concept to save some characters, and characters are free while concepts are expensive.\n\n## What you are actually shipping\n\nWhen you choose the boring version, you are shipping something to a future person: a faster incident, a cleaner handoff, a change that takes an hour instead of a day. That person cannot thank you, but you can be that person, and you will be, on some night when a customer cannot check out and the fix has to be right the first time.\n\nThe engineers I trust with revenue-critical code are not the ones who write the most impressive line. They are the ones whose code I can read while half asleep and change without fear, and it is the same lens I bring when I [read an unfamiliar codebase for the first time](/blog/the-refund-path-is-where-i-read-a-codebases-soul). That is the highest compliment I give code, and it is why I spend my review capital defending the plain version and my own time deleting the clever one. Boring is not the absence of skill. On systems that have to last, it is the skill.\n\n## The takeaway\n\nBoring code is a gift you ship to whoever debugs the system next, and on software that has to last, that person is usually you. Argue for the plain version from the failure mode rather than taste, spend your review capital defending it, and delete the clever line you were proud of when a simpler one exists. That discipline is not the absence of skill; on systems where the failure mode is money and trust, it is the skill.\n\n## FAQ\n\n**Isn't boring code just an excuse for writing less skilled code?**\nNo. A well-chosen abstraction is boring in the best way, hiding complexity behind an obvious name. The discipline is making every abstraction pay for the concept it adds, which most clever code fails to do.\n\n**How do I defend the plain version when a reviewer calls it verbose?**\nArgue from the reader, not the writer. Count the concepts someone must hold to be sure it is correct, and ask how they would change it under a deadline. Failure-mode arguments end in agreement where taste arguments do not.\n\n**When is clever code actually worth keeping?**\nWhen you could delete it without hesitation if a simpler version existed and still choose it on the merits. If your honest answer is \"no, I like it\", the attachment is the problem, not a reason to keep it.\n\n**Why does this matter more for billing and payments than other code?**\nBecause the failure mode is money and trust, not a slow page. When a customer cannot check out at 11pm, you want to read three plain sentences, not reverse engineer a null coalescing operator.\n\n**Does boring mean I should never add a dependency or abstraction?**\nNo, it means the abstraction has to earn the concept it introduces. Say no to the one that saves four lines and adds a concept, and yes to the one that hides real complexity behind an obvious name.\n",[265,268,271,274,277],{"q":266,"a":267},"Isn't boring code just an excuse for writing less skilled code?","No. A well-chosen abstraction is boring in the best way, hiding complexity behind an obvious name. The discipline is making every abstraction pay for the concept it adds, which most clever code fails to do.",{"q":269,"a":270},"How do I defend the plain version when a reviewer calls it verbose?","Argue from the reader, not the writer. Count the concepts someone must hold to be sure it is correct, and ask how they would change it under a deadline. Failure-mode arguments end in agreement where taste arguments do not.",{"q":272,"a":273},"When is clever code actually worth keeping?","When you could delete it without hesitation if a simpler version existed and still choose it on the merits. If your honest answer is \"no, I like it\", the attachment is the problem, not a reason to keep it.",{"q":275,"a":276},"Why does this matter more for billing and payments than other code?","Because the failure mode is money and trust, not a slow page. When a customer cannot check out at 11pm, you want to read three plain sentences, not reverse engineer a null coalescing operator.",{"q":278,"a":279},"Does boring mean I should never add a dependency or abstraction?","No, it means the abstraction has to earn the concept it introduces. Say no to the one that saves four lines and adds a concept, and yes to the one that hides real complexity behind an obvious name.",{"slug":281,"title":282,"description":283,"date":284,"updated":177,"tags":285,"pillar":150,"draft":17,"image":151,"imageAlt":152,"markdown":288,"faq":289,"readingTimeText":200,"readingTimeMinutes":201},"six-payments-edge-cases-that-bite-at-2am","Six payments edge cases that bite at 2am","Partial refunds, duplicate webhooks, currency rounding, retries, chargebacks, and clock skew: the payments edge cases that page you, with the fix for each.","2026-06-10T00:00:00.000Z",[146,179,286,53,287],"idempotency","reliability","\n## TL;DR\n\nPayments code fails at 2am in six predictable places: partial refunds, duplicate webhooks, currency rounding, retries, chargebacks, and clock skew. The fix for nearly all: assume every operation runs more than once, out of order, and stay correct anyway.\n\nPayments code fails in the same six places, and it fails at 2am because that is when retries, cron jobs, and other timezones collide. The happy path is a solved problem. What pages you is partial refunds, duplicate webhooks, currency rounding, retry storms, chargebacks, and clock skew. Here is the failure mode for each, and the fix I actually reach for, drawn from shipping this across a subscription plugin, a WooCommerce marketplace, and a Laravel service that bills for usage.\n\n## 1. Partial refunds derived by division\n\n**The failure.** A customer refunds one of three seats. The code divides the order total by three to get the refund amount. The price does not divide cleanly, so you refund a cent too much. Multiply by volume and your gateway settlement stops matching your books.\n\n**The fix.** Never derive a refund by dividing a total. It is the first tell I look for when I [read a payments codebase's refund path](/blog/the-refund-path-is-where-i-read-a-codebases-soul). Store integer cents per line item at purchase time, refund against the line item, and cap it:\n\n```php\n$remaining = $item->quantity - $item->refunds()->sum('quantity');\nif ($qty > $remaining) {\n    throw new RefundExceedsRemainingException($item->id, $remaining);\n}\n$refundCents = $item->unit_price_cents * $qty; // integer math, no division\n```\n\nIf your money type is a float anywhere in this path, that is the bug, and it is only sleeping.\n\n## 2. Duplicate webhooks\n\n**The failure.** Every gateway [redelivers webhooks](https://docs.stripe.com/webhooks), retrying on a schedule until it gets a timely 2xx. A `payment.succeeded` event arrives twice because your first 200 was slow to come back. Naive code grants the subscription twice, emails the receipt twice, and increments a usage counter twice.\n\n**The fix.** Treat the event id as an idempotency key and enforce it in the database, not in application logic. A processed-events table with a unique constraint is enough:\n\n```php\ntry {\n    DB::table('processed_webhooks')->insert([\n        'event_id'     => $payload['id'],\n        'processed_at' => now(),\n    ]);\n} catch (UniqueConstraintViolationException) {\n    return response()->noContent(); // already handled, ack and move on\n}\n\n$this->apply($payload); // safe: runs at most once per event id\n```\n\nThe \"check if exists, then insert\" version loses under concurrency, and webhook redelivery is concurrency: the retry often lands while the first delivery is still in flight. Let the unique index be the referee. Also, acknowledge fast. Do the heavy work after you return 200, or the gateway times out waiting and redelivers, which is the exact storm you were trying to avoid.\n\n## 3. Currency rounding and the fifty-cent invoice that says fifty-one\n\n**The failure.** You compute tax or proration in floating point. `0.1 + 0.2` is famously not `0.3` in [IEEE floats](https://www.php.net/manual/en/language.types.float.php), and a monthly plan prorated across an odd number of days lands on a fraction of a cent. The customer sees an invoice total that does not equal the sum of its lines, and support cannot explain it.\n\n**The fix.** [Money is integer minor units, end to end](/blog/store-money-as-integer-cents-never-a-float). Cents in the database, cents in the domain logic, format to a decimal string only at the very edge where you render it. When you must split an amount, distribute the remainder deterministically instead of rounding each slice:\n\n```php\nfunction splitCents(int $total, int $parts): array\n{\n    $base = intdiv($total, $parts);\n    $remainder = $total % $parts;\n    // Hand the leftover cents to the first `remainder` slices, one each.\n    return array_map(\n        fn ($i) => $base + ($i \u003C $remainder ? 1 : 0),\n        range(0, $parts - 1)\n    );\n}\n// splitCents(1000, 3) => [334, 333, 333], sums to exactly 1000\n```\n\nThe sum is guaranteed to equal the input. No slice is ever rounded in isolation, so the invoice always reconciles.\n\n## 4. Retries without backoff, and retries without idempotency\n\n**The failure.** A charge or a provider call fails transiently, so you retry in a tight loop. Now you are hammering a struggling gateway, and if the original request actually succeeded but the response got lost, you charge the customer again.\n\n**The fix.** Retries need two things that usually ship separately: exponential backoff with jitter, and an idempotency key so a retry of a succeeded call is a no-op at the provider. Every serious payment API supports an [idempotency key on the request](https://docs.stripe.com/api/idempotent_requests), replaying the original response instead of creating a second charge; use it.\n\n```php\n$attempt = 0;\n$key = $intent->idempotency_key; // stable across retries of THIS charge\n\nretry:\ntry {\n    return $gateway->charge($intent->amount_cents, $key);\n} catch (TransientGatewayException $e) {\n    if (++$attempt >= 5) throw $e;\n    // exponential backoff with jitter, capped\n    $sleepMs = min(2 ** $attempt * 100, 8000) + random_int(0, 250);\n    usleep($sleepMs * 1000);\n    goto retry;\n}\n```\n\nThe jitter matters more than people think. Without it, a fleet of workers that all failed at the same second retries at the same second, and you rebuild the exact spike that caused the failure. In my mail service, this same pattern (stable key plus jittered backoff) is what lets me fail a send over to a second provider without double-delivering.\n\n## 5. Chargebacks are not refunds\n\n**The failure.** A chargeback arrives as a webhook and the code routes it through the refund handler, or worse, ignores it because \"the money question is between the customer and their bank.\" A chargeback is a dispute with a deadline, a fee, and evidence you must submit. Treated as a refund, you lose the dispute by default and eat the fee on top of the reversed amount.\n\n**The fix.** Model disputes as their own entity with a state machine and, critically, a due date you act on:\n\n```php\nDispute::create([\n    'order_id'       => $order->id,\n    'gateway_ref'    => $payload['dispute_id'],\n    'amount_cents'   => $payload['amount'],\n    'reason_code'    => $payload['reason'],\n    'status'         => 'needs_response',\n    'evidence_due_at'=> $payload['evidence_due_by'], // this is the whole point\n]);\n```\n\nThen a scheduled job flags disputes approaching `evidence_due_at` so a human submits evidence before the window closes. And revoke entitlement on a lost dispute the same way you would on a confirmed refund, or you have paid twice: once to the bank, once by leaving the product switched on.\n\nThe habit that saves you here is treating the evidence deadline as the real deliverable, not the dispute itself. Most disputes are won or lost on whether a human actually submitted something before the window closed, so the code's only job is to make that deadline impossible to miss. Being clever about the outcome comes second.\n\n## 6. Clock skew and trusting the client's time\n\n**The failure.** Anything that uses time from an untrusted source. A license check that reads the client's system clock and decides the license is still valid. A \"this token expires in 60 seconds\" webhook signature verified against a server whose clock drifted. A grace period computed from a timestamp the client sent. Clocks lie, both by accident (drift) and on purpose (a user rolling their clock back to dodge an expiry).\n\n**The fix.** Server time is the only time that gets to make money decisions. Verify webhook signatures with a tolerance window so honest skew passes and stale replays fail:\n\n```php\n$skew = abs(time() - (int) $payload['timestamp']);\nif ($skew > 300) { // 5 minutes each way\n    abort(400, 'timestamp outside tolerance');\n}\n```\n\nFor expiry, compare against a server-trusted \"now,\" never against a value the client controls. When I build offline license checks, the client can cache a last-known-good state, but the authoritative expiry is a server-signed timestamp, and the [grace window](/blog/subscriptions-are-licensing-that-bills-on-time) is measured from the server's clock, not the device's. Five minutes of tolerance forgives real drift; it does not forgive a clock rolled back a year.\n\n## The pattern under the patterns\n\nFive of these six come down to one habit: assume the operation will run more than once, out of order, and at the worst possible moment, and make it correct anyway. Idempotency keys, integer money, backoff with jitter, server time, and a real state machine for anything money touches. None of it is clever. All of it is the difference between sleeping through the night and explaining a double charge to a customer at 2am.\n\nThe cheapest dispute is the one you never create, which is a practical argument for [refunding without friction while the conversation still teaches you something](/blog/when-to-automate-refunds).\n\n## The takeaway\n\nThese six failures are not exotic. They are the ordinary result of assuming a payment operation runs exactly once, in order, at a convenient time, when in production it does none of those things. Build for repetition, reordering, and the worst possible moment with idempotency keys, integer money, jittered backoff, server time, and explicit state machines, and the 2am page stops coming.\n\n## FAQ\n\n**Why do payments bugs specifically surface at 2am?**\nThat is when retries, cron jobs, and other timezones collide, so duplicate deliveries and clock issues stack up. The happy path is a solved problem; the pages come from concurrency and time.\n\n**Can I just use floats for money if I round at the end?**\nNo. Any float anywhere in the money path is a sleeping bug, because rounding each slice in isolation makes an invoice total stop matching the sum of its lines. Keep integer minor units end to end and format to a decimal string only when you render.\n\n**Why enforce webhook idempotency in the database instead of in code?**\nBecause webhook redelivery is concurrency: the retry often lands while the first delivery is still in flight, and a check-then-insert loses that race. A unique index on the event id is the referee that application logic cannot be.\n\n**Are chargebacks just refunds I should automate?**\nNo. A chargeback is a dispute with a deadline, a fee, and evidence you must submit, so route it through refund logic and you lose by default and eat the fee on top. Model it as its own entity with an evidence due date a scheduled job acts on.\n\n**Whose clock decides whether a license or grace period is still valid?**\nThe server's, always. Client clocks drift by accident and get rolled back on purpose, so verify signatures with a tolerance window and compute expiry against a server-trusted now.\n\n**What is the single habit that prevents most of these?**\nAssume every money operation will run more than once, out of order, at the worst time, and make it correct anyway. Idempotency keys, integer money, backoff with jitter, and server time cover five of the six.\n",[290,293,296,299,302,305],{"q":291,"a":292},"Why do payments bugs specifically surface at 2am?","That is when retries, cron jobs, and other timezones collide, so duplicate deliveries and clock issues stack up. The happy path is a solved problem; the pages come from concurrency and time.",{"q":294,"a":295},"Can I just use floats for money if I round at the end?","No. Any float anywhere in the money path is a sleeping bug, because rounding each slice in isolation makes an invoice total stop matching the sum of its lines. Keep integer minor units end to end and format to a decimal string only when you render.",{"q":297,"a":298},"Why enforce webhook idempotency in the database instead of in code?","Because webhook redelivery is concurrency: the retry often lands while the first delivery is still in flight, and a check-then-insert loses that race. A unique index on the event id is the referee that application logic cannot be.",{"q":300,"a":301},"Are chargebacks just refunds I should automate?","No. A chargeback is a dispute with a deadline, a fee, and evidence you must submit, so route it through refund logic and you lose by default and eat the fee on top. Model it as its own entity with an evidence due date a scheduled job acts on.",{"q":303,"a":304},"Whose clock decides whether a license or grace period is still valid?","The server's, always. Client clocks drift by accident and get rolled back on purpose, so verify signatures with a tolerance window and compute expiry against a server-trusted now.",{"q":306,"a":307},"What is the single habit that prevents most of these?","Assume every money operation will run more than once, out of order, at the worst time, and make it correct anyway. Idempotency keys, integer money, backoff with jitter, and server time cover five of the six.",{"slug":309,"title":310,"description":311,"date":312,"updated":177,"tags":313,"pillar":211,"draft":17,"image":212,"imageAlt":213,"markdown":316,"faq":317,"readingTimeText":138,"readingTimeMinutes":139},"shipping-a-feature-into-a-five-year-old-plugin","Shipping a feature into a five-year-old plugin","Real tactics for shipping into an old WordPress plugin: feature flags, backward-compatible DB migrations, and staged rollouts across a live install base.","2026-06-03T00:00:00.000Z",[54,208,314,315,52],"feature-flags","rollouts","\n## TL;DR\n\nShipping into an old plugin should be a config change, not an event. Separate three decisions: ship the code, enable the behavior, and widen who sees it, using a database feature flag, an additive migration, and a staged percentage rollout.\n\nShipping a feature into a five-year-old plugin should be uneventful. If launching a feature needs a war room, a rollback bridge, and everyone on call, the process is broken, not the feature. On the affiliate and licensing plugins I maintain, a new feature reaches the install base in stages, behind a flag, on top of a migration that only adds. It is the same [operate-on-it-awake discipline I use instead of rewriting](/blog/you-dont-rewrite-a-plugin-with-a-big-install-base). By the time it is default-on, it has already been running quietly for weeks. The launch is a config change, not an event.\n\nHere are the three tactics that make that true: feature flags that live in the database, migrations that never destroy, and rollouts that move in percentages instead of all at once.\n\n## Feature flags belong in the options table, not in a constant\n\nThe mistake I see is gating a feature on a PHP constant or the plugin version. That gives you one lever with two positions, on for everyone or off for everyone, and no way to turn it off for one angry customer at 2am without shipping a patch release. A flag needs to be per-site state you can read and write at runtime, which in WordPress means [the options table](https://developer.wordpress.org/reference/functions/get_option/).\n\n```php\nfunction affapp_feature_enabled( $feature, $default = false ) {\n    $flags = get_option( 'affapp_features', array() );\n    if ( ! is_array( $flags ) || ! array_key_exists( $feature, $flags ) ) {\n        return (bool) $default;\n    }\n    return (bool) $flags[ $feature ];\n}\n```\n\nThen the new code sits behind it:\n\n```php\nif ( affapp_feature_enabled( 'tiered_commissions' ) ) {\n    add_filter( 'affapp_calculate_commission', 'affapp_tiered_commission', 10, 3 );\n}\n```\n\nBecause the flag is an option, I can flip it with [WP-CLI's `wp option` command](https://developer.wordpress.org/cli/commands/option/) on a single site, expose it as a hidden setting for beta customers, or force it off from a kill switch if support reports something ugly. The feature ships in the release turned off. Turning it on is a separate, reversible decision I make when I am ready, not something bundled into the moment a user clicks update.\n\nThe kill switch matters more than the enable switch. When something breaks, I want to disable one feature on one site in seconds, not roll the whole plugin back and take every other fix down with it.\n\n## The migration only adds, and it runs once\n\nThe feature needs a new column? Add it. Do not alter the meaning of an existing one, do not drop anything, and do not rename in a step a user cannot undo. [Additive-only migrations](/blog/backward-compatible-migrations-on-an-install-base) are the reason a rollout can be paused or reversed without leaving data in a shape the old code cannot read.\n\nI gate every schema change on a stored version number, separate from the plugin version, because users downgrade and restore backups and I cannot assume the plugin version tracks the actual schema state.\n\n```php\nconst AFFAPP_DB_VERSION = 12;\n\nadd_action( 'plugins_loaded', 'affapp_maybe_upgrade' );\n\nfunction affapp_maybe_upgrade() {\n    $installed = (int) get_option( 'affapp_db_version', 0 );\n    if ( $installed >= AFFAPP_DB_VERSION ) {\n        return;\n    }\n\n    if ( $installed \u003C 12 ) {\n        affapp_migration_12_add_tier_column();\n    }\n\n    update_option( 'affapp_db_version', AFFAPP_DB_VERSION );\n}\n\nfunction affapp_migration_12_add_tier_column() {\n    global $wpdb;\n    $table = $wpdb->prefix . 'affapp_referrals';\n\n    // Guard the ALTER so a re-run does not error on an existing column.\n    $exists = $wpdb->get_var(\n        $wpdb->prepare(\n            \"SELECT COUNT(*) FROM information_schema.COLUMNS\n             WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s\",\n            DB_NAME,\n            $table,\n            'commission_tier'\n        )\n    );\n\n    if ( ! $exists ) {\n        $wpdb->query(\n            \"ALTER TABLE {$table}\n             ADD COLUMN commission_tier VARCHAR(20) NOT NULL DEFAULT 'standard'\"\n        );\n    }\n}\n```\n\nTwo details carry the safety here. The new column has a default, so every existing row is instantly valid without a backfill and the old code, which never selects this column, is unaffected. And the [`information_schema.COLUMNS`](https://dev.mysql.com/doc/refman/8.0/en/information-schema-columns-table.html) check means a half-finished migration that timed out and reloaded does not throw a duplicate-column error. Idempotent steps are the difference between a migration that is safe to retry and one that corrupts state when a shared host kills it at thirty seconds.\n\n## Roll out in percentages, watch, then widen\n\nDefault-on for the whole install base in one release is a bet that your feature is correct against every data shape, host, and [PHP version out there](/blog/supporting-a-plugin-across-wordpress-versions). I do not take that bet. I widen the flag in stages and watch between each.\n\nA cheap way to do staged enablement without a backend service is to derive a stable bucket from something already unique per site.\n\n```php\nfunction affapp_in_rollout( $feature, $percent ) {\n    if ( affapp_feature_enabled( $feature ) ) {\n        return true; // Explicit opt-in always wins.\n    }\n\n    $seed   = get_option( 'affapp_install_id' ); // set once at activation\n    $bucket = hexdec( substr( md5( $seed . '|' . $feature ), 0, 4 ) ) % 100;\n\n    return $bucket \u003C (int) $percent;\n}\n```\n\nThe bucket is stable, so a site that is in the 10 percent cohort stays in it as I widen to 25, then 50, then 100. Nobody flickers in and out of the feature between page loads. I move the percentage up over a week or two, reading support tickets and error logs between steps.\n\nThe thing I watch hardest in the early cohort is the environment, not the feature itself. An unusual host or a PHP version I thought nobody still ran is what turns a clean feature into a fatal on someone's site, and that is exactly why the rollout is a dial I turn on evidence, not a switch I throw on a schedule.\n\nIf the early cohort is quiet, I widen. If it is not, I stop widening, and the kill switch takes the feature off the sites that already have it. Nobody who is not yet in the rollout is affected either way. That is the entire value of the staged approach: a problem is capped at the cohort size, not the install base.\n\n## Why this beats the war room\n\nA war room exists because the whole change lands at once and there is no way to take back one piece of it. Every tactic here is about removing that condition. The flag decouples shipping code from enabling behavior. The additive migration means the old code path is always valid, so pausing costs nothing. The staged rollout caps blast radius to a slice you chose.\n\nPut together, the scary launch becomes a series of small, reversible moves. The code shipped weeks ago, turned off. It ran for the beta cohort, then ten percent, then half, each step boring. When it finally goes default-on, it is the least interesting thing that happened that day, and that is exactly what you want from a plugin that a lot of people are quietly depending on right now.\n\n## The takeaway\n\nShipping into an old plugin stops being scary the moment you stop collapsing three decisions into one. Shipping the code, enabling the behavior, and widening who sees it become separate, reversible levers: a database flag, an additive migration, and a percentage rollout. Pull them one at a time and the launch is a calm afternoon's config change, capped to a cohort you chose, instead of an all-at-once bet that needs a war room.\n\n## FAQ\n\n**Where should a plugin feature flag live?**\nIn the options table as per-site state, not in a PHP constant or the plugin version. That way you can flip it with WP-CLI on a single site, expose it to beta customers, or force it off with a kill switch without shipping a patch release.\n\n**Why does the kill switch matter more than the enable switch?**\nBecause when something breaks you want to disable one feature on one site in seconds, not roll the whole plugin back and take every other fix down with it.\n\n**How do I stage a rollout without a backend service?**\nDerive a stable bucket from something already unique per site, like an install ID hashed with the feature name, then compare it against the target percentage. The bucket is stable, so a site in the ten percent cohort stays in it as you widen to twenty-five, fifty, then one hundred.\n\n**How do I make a migration safe to pause or reverse?**\nOnly add, give new columns a default so existing rows are instantly valid, and guard the change so a re-run does not throw a duplicate-column error. Additive-only means the old code path is always valid, so pausing costs nothing.\n\n**What percentage should I start a rollout at?**\nStart with an explicit beta cohort, then widen through roughly ten, twenty-five, fifty, and one hundred percent over a week or two, reading support tickets and error logs between each step and stopping the moment the early cohort gets noisy.\n",[318,321,324,327,330],{"q":319,"a":320},"Where should a plugin feature flag live?","In the options table as per-site state, not in a PHP constant or the plugin version. That way you can flip it with WP-CLI on a single site, expose it to beta customers, or force it off with a kill switch without shipping a patch release.",{"q":322,"a":323},"Why does the kill switch matter more than the enable switch?","Because when something breaks you want to disable one feature on one site in seconds, not roll the whole plugin back and take every other fix down with it.",{"q":325,"a":326},"How do I stage a rollout without a backend service?","Derive a stable bucket from something already unique per site, like an install ID hashed with the feature name, then compare it against the target percentage. The bucket is stable, so a site in the ten percent cohort stays in it as you widen to twenty-five, fifty, then one hundred.",{"q":328,"a":329},"How do I make a migration safe to pause or reverse?","Only add, give new columns a default so existing rows are instantly valid, and guard the change so a re-run does not throw a duplicate-column error. Additive-only means the old code path is always valid, so pausing costs nothing.",{"q":331,"a":332},"What percentage should I start a rollout at?","Start with an explicit beta cohort, then widen through roughly ten, twenty-five, fifty, and one hundred percent over a week or two, reading support tickets and error logs between each step and stopping the moment the early cohort gets noisy.",{"slug":334,"title":335,"description":336,"date":337,"updated":177,"tags":338,"pillar":85,"draft":17,"image":86,"imageAlt":87,"markdown":340,"faq":341,"readingTimeText":231,"readingTimeMinutes":232},"three-questions-before-i-write-billing-code","Three questions I ask before I write billing code","Three questions to answer before you build billing: what is the source of truth, what happens when this runs twice, and how do we reconstruct it later.","2026-05-27T00:00:00.000Z",[14,339,85],"subscriptions","\n## TL;DR\n\nBefore writing billing code, answer three questions: what is the source of truth, what happens when this runs twice, and how will we reconstruct it later. They prevent drift, double charges, and disputes you cannot win, and cost an afternoon.\n\nBefore I write any billing code, I ask three questions: what is the source of truth, what happens when this runs twice, and how will we reconstruct what happened later. Each one is unglamorous. Each one has saved a founder from a support queue full of double charges and disputes they could not explain. If you steal nothing else from me, steal these three, because the cost of answering them is an afternoon and the cost of skipping them is measured in refunds and trust.\n\nI build subscriptions, licensing, and payment flows for a living, mostly as Laravel apps and WordPress plugins. The bugs that hurt are almost never in the happy path. They live in the assumptions nobody wrote down. These questions drag the assumptions into the open before they cost anything.\n\n## Question one: what is the source of truth?\n\nPick one system that is allowed to be right, and make everything else defer to it. For money, that is almost always your payment provider, not your database. Your database is a cache of what the provider told you. When they disagree, the provider wins and you reconcile.\n\nFounders get this backwards constantly. They treat the local `subscriptions` table as the truth and the provider as a thing they sync to occasionally. Then a webhook gets dropped, the local row says \"active\", the provider says \"canceled for nonpayment\", and the customer keeps using a paid feature for free while your revenue report lies to you.\n\nDeciding the source of truth up front changes the code you write. It means every local write to a billing field is downstream of a provider event, not a user action. It means you build a reconciliation job on day one, not after the first drift incident. And it means when someone asks \"is this customer actually paying us\", you have a defensible answer instead of a guess.\n\n## Question two: what happens when this runs twice?\n\nAssume every billing operation will execute more than once, because eventually it will. [Webhooks retry](https://docs.stripe.com/webhooks). Users double click. A queue redelivers a job after a worker dies mid task. These are the [payment edge cases that bite at 2am](/blog/six-payments-edge-cases-that-bite-at-2am). If running the same operation twice charges the card twice or grants the license twice, you have a bug that only appears under load, which is to say in front of your most active customers.\n\nThe fix is idempotency, and it is cheaper than the bug. Give every operation a key and let the database reject the duplicate:\n\n```php\n// The unique constraint is the actual protection. A check-then-act in\n// application code is a race; the database index is not.\nSchema::create('billing_events', function (Blueprint $table) {\n    $table->id();\n    $table->string('idempotency_key')->unique();\n    $table->string('type');\n    $table->json('payload');\n    $table->timestamps();\n});\n```\n\nWhen you call the provider, pass your own idempotency key too. Stripe and most serious processors [accept one and will return the original result](https://docs.stripe.com/api/idempotent_requests) instead of creating a second charge. That single header turns a scary retry into a safe no-op:\n\n```php\n$stripe->charges->create(\n    ['amount' => 2900, 'currency' => 'usd', 'customer' => $id],\n    ['idempotency_key' => \"invoice-{$invoiceId}\"]\n);\n```\n\nAsk this question before writing the code and idempotency is a design decision. Ask it after an incident and it is a migration under pressure while customers are being double charged.\n\n## Question three: how will we reconstruct this in six months?\n\nEvery billing state change should leave an append-only trail: what changed, when, who triggered it (a record only as trustworthy as [the authentication behind it](/blog/authentication-mistakes-in-early-products)), and the value before and after. Not a status column you overwrite. A log you add to. Because six months from now a customer will dispute a charge, a founder will ask why revenue dipped, or you will need to prove to a processor that a cancellation happened when you say it did. Without the trail, all three end in \"we are not sure\", which on billing means you eat the cost.\n\nThis is also where dunning lives, the [retry-and-notify cycle that subscriptions live or die on](/blog/subscriptions-are-licensing-that-bills-on-time). When a card fails and you retry over several days, you want a record of every attempt, every email, and every outcome. When a customer says \"you never told me my card failed\", the log either backs them up or clears you. Either way you know, and knowing is the whole point.\n\nThe habit that pays off is writing the log entry in the same [database transaction](https://www.postgresql.org/docs/current/tutorial-transactions.html) as the change itself. If the change commits, the record commits. If it rolls back, so does the record. They can never disagree, because they are the same write.\n\n## Why these three and not ten\n\nThere are more good billing questions. These three are the ones I refuse to start without, because each maps to a specific, expensive failure mode. Source of truth prevents silent drift between you and the money. Idempotency prevents double charges. The audit trail prevents unwinnable disputes.\n\nEvery other billing best practice is easier to add later. These three are painful to retrofit because they shape the schema and the transaction boundaries, which is exactly the stuff you do not want to rewrite once real customers depend on it.\n\n## Use them as a founder, not just as an engineer\n\nYou do not need to write the code to ask the questions. Sit with whoever owns billing, ideally [the boring, reliable engineer you hired for exactly this](/blog/the-first-hire-who-touches-your-billing-should-be-boring), and ask all three out loud. Listen for whether the answers are specific. \"The provider is the source of truth and we reconcile nightly\" is a real answer. \"The database, mostly\" is a warning. \"What do you mean, run twice\" is a reason to slow down before launch.\n\nThe three questions take an afternoon to answer honestly and they front load the decisions that are almost impossible to change once money is flowing. Billing rarely fails loudly at first. It drifts, it double charges one customer, it loses one dispute, and then one day the numbers stop making sense. These questions are how you make sure that day never comes, or at least that when something breaks, you can see exactly what broke and fix it before your customers do the seeing for you.\n\nThere is a fourth question that is not about code at all, and it is worth asking before you ship a refund button: [should a human be making this decision](/blog/when-to-automate-refunds) while you are still learning from your customers.\n\n## The takeaway\n\nThese three questions are not a style preference, they are the decisions that are almost impossible to change once real money is flowing. Ask them out loud before launch, whether you write the code or just own the roadmap, and listen for specific answers rather than \"mostly\" and \"what do you mean\". Do that and billing failures become things you can see and fix, instead of a slow drift that surprises you months later.\n\n## FAQ\n\n**Which of the three questions matters most if I only have time for one?**\nSource of truth, because getting it wrong makes every other billing number suspect. Decide the provider is authoritative and reconcile against it, and the rest of the system has something honest to lean on.\n\n**Why is the payment provider the source of truth and not my database?**\nBecause the provider is where the money actually moves; your database is a cache of what it told you. When they disagree, the provider is right and you reconcile to it.\n\n**How much extra work is idempotency really?**\nA unique constraint and an idempotency key passed to the provider, so a scary retry becomes a safe no-op. It is far cheaper than the migration you would run under pressure after customers get double charged.\n\n**What belongs in the audit trail?**\nWhat changed, when, what triggered it, and the value before and after, written in the same transaction as the change itself. That includes dunning: every retry, every email, and every outcome.\n\n**I already launched without these. Is it too late?**\nNo, but expect it to hurt, because these three shape schema and transaction boundaries. Add reconciliation and the audit trail first, then retrofit idempotency where double execution is most expensive.\n",[342,345,348,351,354],{"q":343,"a":344},"Which of the three questions matters most if I only have time for one?","Source of truth, because getting it wrong makes every other billing number suspect. Decide the provider is authoritative and reconcile against it, and the rest of the system has something honest to lean on.",{"q":346,"a":347},"Why is the payment provider the source of truth and not my database?","Because the provider is where the money actually moves; your database is a cache of what it told you. When they disagree, the provider is right and you reconcile to it.",{"q":349,"a":350},"How much extra work is idempotency really?","A unique constraint and an idempotency key passed to the provider, so a scary retry becomes a safe no-op. It is far cheaper than the migration you would run under pressure after customers get double charged.",{"q":352,"a":353},"What belongs in the audit trail?","What changed, when, what triggered it, and the value before and after, written in the same transaction as the change itself. That includes dunning: every retry, every email, and every outcome.",{"q":355,"a":356},"I already launched without these. Is it too late?","No, but expect it to hurt, because these three shape schema and transaction boundaries. Add reconciliation and the audit trail first, then retrofit idempotency where double execution is most expensive.",{"slug":358,"title":359,"description":360,"date":361,"updated":177,"tags":362,"pillar":150,"draft":17,"image":151,"imageAlt":152,"markdown":365,"faq":366,"readingTimeText":200,"readingTimeMinutes":201},"subscriptions-are-licensing-that-bills-on-time","Subscriptions are licensing that bills on time","A subscription is a state machine bolted to a billing clock. Dunning, grace periods, and proration are where it leaks money. Here is how I model each.","2026-05-20T00:00:00.000Z",[339,363,14,364,53],"licensing","state-machine","\n## TL;DR\n\nA subscription is a licensing state machine with a billing clock bolted on, and it leaks money in three places: dunning, grace periods, and proration. Fix each by keeping access computed, history append-only, money in cents, and time on the server.\n\nA subscription is a licensing system with a [billing clock](https://docs.stripe.com/billing/subscriptions/billing-cycle) attached. The hard part was never \"can this account use the product,\" which is the licensing question I have answered many times in WordPress plugins. The hard part is keeping that answer correct while a payment clock ticks in the background and occasionally misfires. If you model a subscription as a status column instead of a state machine, it will leak money in three predictable places: dunning, grace periods, and proration. This post walks each one.\n\n## Start from the licensing question, then add the clock\n\nI build license-key systems, so I am used to reducing entitlement to one function: given this account and this moment, is access allowed? A subscription is that same function with a payment obligation layered on. The mistake is letting billing events write access directly. A failed charge should not immediately flip the customer to \"no access,\" and a successful charge should not be the only thing that grants it. Access is derived from state. Billing events move the state.\n\nSo the first thing I do is name the states honestly:\n\n```\ntrialing -> active -> past_due -> grace -> canceled\n                          \\-> active (recovered)\n                grace     -> canceled (dunning exhausted)\n```\n\nEvery transition is an event with a reason and a timestamp, written to a log, never an in-place overwrite of a `status` string. When a customer disputes why they lost access, I want to replay the sequence, not guess from a single mutable field. This is the same discipline I use for license state: entitlement is computed, its history is append-only.\n\n## Where it leaks, part one: dunning\n\nDunning is the retry schedule for a failed renewal, and it is the single biggest revenue leak in naive subscription code. The failure mode is binary thinking: the charge failed, so cancel. But most failed renewals are not customers leaving. They are an expired card, a temporary hold, or a bank declining a foreign transaction. Cancel on the first failure and you are firing paying customers over a card that would have worked on Tuesday.\n\nThe fix is a retry schedule with backoff, driven by a scheduled job, that only gives up after the schedule is exhausted:\n\n```php\n// Retry a failed renewal on a widening schedule, in days.\nconst DUNNING_SCHEDULE = [1, 3, 5, 7];\n\npublic function attemptRenewal(Subscription $sub): void\n{\n    $result = $this->gateway->charge($sub->amount_cents, $sub->renewal_key);\n\n    if ($result->succeeded()) {\n        $sub->transitionTo('active', reason: 'renewal_paid');\n        return;\n    }\n\n    $attempt = $sub->dunning_attempt + 1;\n\n    if ($attempt > count(self::DUNNING_SCHEDULE)) {\n        $sub->transitionTo('canceled', reason: 'dunning_exhausted');\n        return;\n    }\n\n    $sub->update([\n        'status'          => 'past_due',\n        'dunning_attempt' => $attempt,\n        'next_retry_at'   => now()->addDays(self::DUNNING_SCHEDULE[$attempt - 1]),\n    ]);\n    $this->notifyPaymentFailed($sub, $attempt); // and give them a link to fix the card\n}\n```\n\nTwo things earn their keep here. The widening schedule (1, 3, 5, 7 days) spreads retries across the customer's likely payday and gives a declined bank time to relent. And `renewal_key` is a [stable idempotency key](/blog/six-payments-edge-cases-that-bite-at-2am), so if the charge actually succeeded but the response was lost, the next attempt does not bill them twice, because the provider [returns the original result rather than creating a second charge](https://docs.stripe.com/api/idempotent_requests). Dunning that double-charges is worse than dunning that gives up early, because now you have created the chargeback yourself.\n\nThe principle I hold is that dunning is a recovery mechanism, not an eviction notice. Widening the gaps between retries almost always recovers more than tightening them, because you are waiting for paydays to arrive and bank holds to clear, not punishing a customer for a card that would have worked in a few days.\n\n## Where it leaks, part two: grace periods\n\nA grace period is the gap between \"payment is late\" and \"access is gone.\" Set it to zero and you punish good customers for a bank's timing. Set it to infinite and you give the product away. This is the exact tradeoff I live with in offline license checks, where the activation server can be briefly unreachable and I still owe the paying customer a working product.\n\nThe rule I use: fail open for a bounded window, then fail closed. Access stays on through `past_due` and a defined `grace` window, and only flips off when dunning is genuinely exhausted:\n\n```php\npublic function hasAccess(Subscription $sub, Carbon $now): bool\n{\n    return match ($sub->status) {\n        'trialing', 'active' => true,\n        'past_due'           => true, // still retrying, keep them in\n        'grace'              => $now->lessThan($sub->grace_ends_at),\n        default              => false, // canceled, expired\n    };\n}\n```\n\nThe number in `grace_ends_at` is a business decision, not an engineering one, and it should be a config value someone can change without a deploy. What is not negotiable is that the window is measured against server time, never the client's clock. A license or subscription that reads the device clock for its grace math can be defeated by rolling the clock back, and I have seen that attempted. Server time makes the money decision. The client only caches the last answer.\n\n## Where it leaks, part three: proration\n\nProration is what you owe when someone changes plans mid-cycle. Upgrade on day ten of a thirty-day month and you charge for the unused twenty days at the new rate, credit the unused twenty at the old rate, and the difference is what they pay today. That credit-then-charge shape is the same one [Stripe documents for prorations](https://docs.stripe.com/billing/subscriptions/prorations), down to negative amounts not being refunded automatically. Get the arithmetic wrong and every plan change either overcharges (support ticket) or undercharges (silent leak), and at scale the silent leak is the expensive one because nobody complains about being charged too little.\n\nThe mistakes are always the same two: floating point, and counting the boundary day twice. I do proration in [integer cents](/blog/store-money-as-integer-cents-never-a-float), on whole days, with the seconds truncated so a change at 11pm and a change at 1am on the same day behave identically:\n\n```php\npublic function prorationCents(Subscription $sub, Plan $newPlan, Carbon $now): int\n{\n    $periodDays  = $sub->current_period_start->diffInDays($sub->current_period_end);\n    $usedDays    = $sub->current_period_start->diffInDays($now); // whole days\n    $unusedDays  = max(0, $periodDays - $usedDays);\n\n    $creditCents = intdiv($sub->plan->amount_cents * $unusedDays, $periodDays);\n    $chargeCents = intdiv($newPlan->amount_cents  * $unusedDays, $periodDays);\n\n    return $chargeCents - $creditCents; // may be negative: that is a credit, not a charge\n}\n```\n\nThe negative case is the one juniors forget. A downgrade produces a credit, and a credit is not [a refund you push to a card](/blog/the-refund-path-is-where-i-read-a-codebases-soul), it is a balance you carry to the next invoice. If your code assumes proration is always a positive charge, downgrades silently drop money on the floor or, worse, try to \"refund\" a credit that was never a real payment.\n\n## The unifying view\n\nLicensing answers one question: is access allowed right now. Subscriptions answer the same question while a billing clock runs and sometimes fails. Every leak I have named is a place where a billing event was allowed to overwrite access instead of nudging a state machine that derives it. Keep access computed, keep the history append-only, keep money in integer cents, keep time on the server, and let dunning, grace, and proration each be an explicit, testable transition.\n\nDo that and a subscription stops being a fragile status column and becomes what it always was: licensing that also has to bill you on time, and get both right at once.\n\n## The takeaway\n\nEvery subscription leak I have named comes from the same mistake: letting a billing event write access directly instead of nudging a state machine that derives it. Answer the licensing question with computed access, move the state with events you log, keep money in integer cents, and keep time on the server, and dunning, grace, and proration each become an explicit, testable transition. That is the difference between a subscription that quietly bleeds revenue and one that gets both jobs right at once.\n\n## FAQ\n\n**Why treat a subscription as a state machine rather than a status column?**\nBecause access has to stay correct while a billing clock ticks and sometimes misfires, and a single mutable status field cannot record why a transition happened. Deriving access from logged state lets you replay the sequence when a customer disputes losing access.\n\n**Should a failed renewal charge cancel the subscription immediately?**\nNo. Most failed renewals are an expired card, a temporary hold, or a bank declining a foreign transaction, not a customer leaving, so cancel on the first failure and you fire paying customers over a card that would have worked days later.\n\n**What stops dunning retries from double-charging?**\nA stable idempotency key on the renewal, so if a charge actually succeeded but the response was lost, the next attempt is a no-op at the gateway. Dunning that double-charges is worse than dunning that gives up, because you create the chargeback yourself.\n\n**How long should a grace period be?**\nThat is a business decision, so make it a config value someone can change without a deploy. The engineering rule is fixed: fail open for a bounded window, then fail closed, and measure the window against server time so it cannot be defeated by rolling the client clock back.\n\n**Is proration always a charge?**\nNo, and the negative case is the one juniors forget. A downgrade produces a credit, which is a balance you carry to the next invoice, not a refund you push to a card, so code that assumes a positive charge silently drops that money.\n\n**How is this different from plain licensing?**\nLicensing answers one question: is access allowed right now. A subscription answers the same question while a billing clock runs and occasionally fails, so it is licensing plus dunning, grace, and proration done correctly at the same time.\n",[367,370,373,376,379,382],{"q":368,"a":369},"Why treat a subscription as a state machine rather than a status column?","Because access has to stay correct while a billing clock ticks and sometimes misfires, and a single mutable status field cannot record why a transition happened. Deriving access from logged state lets you replay the sequence when a customer disputes losing access.",{"q":371,"a":372},"Should a failed renewal charge cancel the subscription immediately?","No. Most failed renewals are an expired card, a temporary hold, or a bank declining a foreign transaction, not a customer leaving, so cancel on the first failure and you fire paying customers over a card that would have worked days later.",{"q":374,"a":375},"What stops dunning retries from double-charging?","A stable idempotency key on the renewal, so if a charge actually succeeded but the response was lost, the next attempt is a no-op at the gateway. Dunning that double-charges is worse than dunning that gives up, because you create the chargeback yourself.",{"q":377,"a":378},"How long should a grace period be?","That is a business decision, so make it a config value someone can change without a deploy. The engineering rule is fixed: fail open for a bounded window, then fail closed, and measure the window against server time so it cannot be defeated by rolling the client clock back.",{"q":380,"a":381},"Is proration always a charge?","No, and the negative case is the one juniors forget. A downgrade produces a credit, which is a balance you carry to the next invoice, not a refund you push to a card, so code that assumes a positive charge silently drops that money.",{"q":383,"a":384},"How is this different from plain licensing?","Licensing answers one question: is access allowed right now. A subscription answers the same question while a billing clock runs and occasionally fails, so it is licensing plus dunning, grace, and proration done correctly at the same time.",{"slug":386,"title":387,"description":388,"date":389,"updated":177,"tags":390,"pillar":211,"draft":17,"image":212,"imageAlt":213,"markdown":392,"faq":393,"readingTimeText":200,"readingTimeMinutes":201},"backward-compatible-migrations-on-an-install-base","Backward-compatible migrations on a big install base","The migration rules I hold on a live WordPress install base: additive columns only, no destructive deploys, version gates, and a rollback plan.","2026-05-13T00:00:00.000Z",[54,210,391,148,52],"sql","\n## TL;DR\n\nA migration on a live install base runs on hardware you do not own. Hold five rules: add only with defaults, never destroy in a deploy, gate on a stored schema version, keep steps idempotent, and know the rollback first.\n\nA migration on a live install base is not a schema change, it is a bet that runs on hardware you do not own. On the licensing and affiliate plugins I maintain, a single migration executes on shared hosts with thirty-second timeouts, on databases with millions of rows, and on [PHP versions I stopped supporting years ago but users still run](/blog/supporting-a-plugin-across-wordpress-versions). So I hold a short list of rules I do not break: add only, never destroy in a deploy, gate every change on a version number, and always know how I roll back. Break one and you are one `ALTER` away from a support queue full of stores that stopped working.\n\nHere are the rules, and the code that enforces each one.\n\n## Rule 1: additive only, and give every new column a default\n\nNew feature needs a column? Add it with a default so every existing row is valid the instant the column exists. Do not add a `NOT NULL` column with no default onto a populated table, because the database has to decide what the existing millions of rows hold, and on some engines and versions that is an error or a full-table rewrite that blows the timeout. MySQL's [online DDL reference](https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html) spells out which `ALTER TABLE` operations rebuild the table instead of just changing metadata.\n\n```sql\nALTER TABLE wp_affapp_referrals\n  ADD COLUMN commission_tier VARCHAR(20) NOT NULL DEFAULT 'standard';\n```\n\nThat default is doing real work. The old code never selects `commission_tier`, so it keeps working untouched. The new code reads a valid value from every row, old and new, with no separate backfill pass. One additive statement with a sane default beats a two-phase add-then-backfill for anything that can be defaulted, because there is no window where rows are half-migrated, which is exactly what lets me [ship a new feature in staged percentages](/blog/shipping-a-feature-into-a-five-year-old-plugin) on top of it.\n\nWhen you truly cannot default the value, the new column is nullable, and the code treats `NULL` as \"not yet computed\" rather than assuming presence. You backfill in batches, not in the deploy.\n\n## Rule 2: no destructive change in a deploy\n\nDropping a column, renaming a column, or narrowing a type is a one-way door on a system a user can restore from a backup at any moment. If the old table shape is gone and they roll the plugin back, the old code hits a column that no longer exists and fatals on every request. So destructive changes do not ride in the same release that stops using the column. They wait.\n\nThe retirement of a column is a three-release arc, not a step:\n\n1. Stop writing to the column. Both old and new code still read it.\n2. Stop reading from it. It sits there, ignored, for at least one full release so anyone who downgrades still has it.\n3. Only then, in a much later release, drop it, once no shipped version of the plugin references it.\n\nThe column being dead for a release is cheap. A destructive deploy that meets a downgrade is not. This staged retirement is the same patience I bring to [evolving a plugin without a rewrite](/blog/you-dont-rewrite-a-plugin-with-a-big-install-base): let the old path die on its own schedule, never in one cut.\n\n## Rule 3: gate every change on a stored schema version\n\nI never key migrations off the plugin version. Users downgrade, restore staging over production, and clone sites. The plugin version does not reliably tell me what shape the schema is actually in. So I keep a dedicated schema version in the options table and compare against it on load.\n\n```php\nconst AFFAPP_DB_VERSION = 15;\n\nadd_action( 'plugins_loaded', 'affapp_maybe_upgrade' );\n\nfunction affapp_maybe_upgrade() {\n    $installed = (int) get_option( 'affapp_db_version', 0 );\n    if ( $installed >= AFFAPP_DB_VERSION ) {\n        return; // Already current. Cheap early return on every request.\n    }\n\n    require_once __DIR__ . '/includes/migrations.php';\n\n    // Ordered, cumulative, each step idempotent.\n    if ( $installed \u003C 14 ) {\n        affapp_migration_14_add_tier_column();\n    }\n    if ( $installed \u003C 15 ) {\n        affapp_migration_15_add_payout_index();\n    }\n\n    update_option( 'affapp_db_version', AFFAPP_DB_VERSION );\n}\n```\n\nThe steps are cumulative and ordered, so a site three versions behind runs 14 then 15 in sequence and lands current. The early return keeps the check nearly free on the vast majority of requests where nothing needs to happen. And critically, each step is written to be safe to run twice, because on a host that kills the request at thirty seconds mid-migration, the next page load will run it again.\n\n## Rule 4: every migration step is idempotent\n\nIdempotent means running it twice does no additional harm. This is not optional on WordPress, where you do not control the execution environment and a timeout mid-migration is normal, not exceptional. Guard every structural change with a check.\n\n```php\nfunction affapp_migration_15_add_payout_index() {\n    global $wpdb;\n    $table = $wpdb->prefix . 'affapp_payouts';\n\n    $has_index = $wpdb->get_var(\n        $wpdb->prepare(\n            \"SELECT COUNT(*) FROM information_schema.STATISTICS\n             WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s\",\n            DB_NAME,\n            $table,\n            'affiliate_paid_at'\n        )\n    );\n\n    if ( ! $has_index ) {\n        $wpdb->query(\n            \"ALTER TABLE {$table}\n             ADD INDEX affiliate_paid_at (affiliate_id, paid_at)\"\n        );\n    }\n}\n```\n\nFor table creation, [`dbDelta()`](https://developer.wordpress.org/reference/functions/dbdelta/) gives you idempotency for free: it creates a table that is missing and leaves a matching one alone. For an `ALTER` on an existing table, `dbDelta()` is fussier and will not reliably add an index to a table it did not just create, so I check [`information_schema.STATISTICS`](https://dev.mysql.com/doc/refman/8.0/en/information-schema-statistics-table.html) myself and only run the change when the object is genuinely absent. The result is a step I can run on a fresh install, a half-migrated install, or a fully current one, and it does the right thing in all three.\n\n## Rule 5: know the rollback before you deploy\n\nIf I cannot describe the rollback in one sentence before shipping, the migration is not ready. Because every change up to this point was additive and idempotent, the rollback is usually trivial: the old code ignores the new column, so reverting the plugin is a clean downgrade with no schema surgery.\n\nThe test I use is whether I would run the rollback myself on a live customer's site without hesitating. If the honest answer is that I would want a fresh backup in hand first, the migration is not additive enough yet, and I go back and make it so before shipping.\n\nThe rollback plan I write down for each migration is short and specific:\n\n- The new column has a default, so the previous release reads every row without it. Downgrade is safe with no DB action.\n- The new index only affects query speed, never correctness. Leaving it in place after a downgrade costs nothing.\n- If a batched backfill is running, it is resumable and can simply be stopped, because it only fills a nullable column the old code does not read.\n\nNotice what is not on that list: \"restore the database from backup.\" That is the plan you fall back to when you broke rule 1 or rule 2. If the migration was additive and idempotent, you never need it.\n\n## The rules are the product\n\nNone of this is clever. It is the opposite of clever, and that is the point. On a plugin thousands of people run in production, the migration that never makes the news is the one that added a defaulted column behind a version gate, ran once, and could be rolled back by clicking downgrade. Additive only, never destructive in a deploy, gated on a stored version, idempotent, with a rollback you wrote down first. I do not break these rules, because the one time you do is the one time a store stops taking orders and it is your name on the changelog.\n\n## The takeaway\n\nThe five rules hold precisely because none of them is sophisticated. Add only, never destroy in a deploy, gate on a stored schema version, keep every step idempotent, and write the rollback down before you ship. Follow them and a migration on someone else's shared host with a thirty-second timeout becomes a non-event: a defaulted column behind a version gate that ran once and could be undone by clicking downgrade. The rules are the product.\n\n## FAQ\n\n**Why give every new column a default?**\nSo every existing row is valid the instant the column exists, with no separate backfill pass. The old code never selects the column, so it keeps working untouched, and there is no window where rows are half-migrated.\n\n**Can I drop or rename a column in the same release that stops using it?**\nNo. Retire a column across three releases: stop writing to it, then stop reading from it for at least one full release so downgraders still have it, then drop it only in a much later release once no shipped version references it.\n\n**Why gate migrations on a stored schema version instead of the plugin version?**\nBecause users downgrade, restore staging over production, and clone sites, so the plugin version does not reliably tell you what shape the schema is actually in. A dedicated schema version in the options table does.\n\n**How do I make an ALTER idempotent on WordPress?**\nCheck information_schema for the column or index and run the change only when it is genuinely absent. dbDelta() handles table creation for free, but it will not reliably add an index to a table it did not just create, so guard the ALTER yourself.\n\n**What is the rollback for an additive migration?**\nUsually nothing. The old code ignores the new column, so reverting the plugin is a clean downgrade with no schema surgery. \"Restore the database from backup\" is only the fallback for when you broke the additive-only or no-destructive-deploy rule.\n",[394,397,400,403,406],{"q":395,"a":396},"Why give every new column a default?","So every existing row is valid the instant the column exists, with no separate backfill pass. The old code never selects the column, so it keeps working untouched, and there is no window where rows are half-migrated.",{"q":398,"a":399},"Can I drop or rename a column in the same release that stops using it?","No. Retire a column across three releases: stop writing to it, then stop reading from it for at least one full release so downgraders still have it, then drop it only in a much later release once no shipped version references it.",{"q":401,"a":402},"Why gate migrations on a stored schema version instead of the plugin version?","Because users downgrade, restore staging over production, and clone sites, so the plugin version does not reliably tell you what shape the schema is actually in. A dedicated schema version in the options table does.",{"q":404,"a":405},"How do I make an ALTER idempotent on WordPress?","Check information_schema for the column or index and run the change only when it is genuinely absent. dbDelta() handles table creation for free, but it will not reliably add an index to a table it did not just create, so guard the ALTER yourself.",{"q":407,"a":408},"What is the rollback for an additive migration?","Usually nothing. The old code ignores the new column, so reverting the plugin is a clean downgrade with no schema surgery. \"Restore the database from backup\" is only the fallback for when you broke the additive-only or no-destructive-deploy rule.",{"slug":410,"title":411,"description":412,"date":413,"updated":177,"tags":414,"pillar":85,"draft":17,"image":86,"imageAlt":87,"markdown":417,"faq":418,"readingTimeText":138,"readingTimeMinutes":139},"build-vs-buy-for-billing","Build vs buy for billing: when Stripe is enough","For most early products a hosted processor like Stripe is enough. It stops at four: global tax, usage-based pricing, marketplaces, and provider limits.","2026-05-06T00:00:00.000Z",[14,415,416,85],"build-vs-buy","merchant-of-record","\n## TL;DR\n\nBuy a hosted processor like Stripe by default and own only the logic that is truly yours, usually metering and rating. The four moments to watch: global tax, usage-based pricing, becoming a marketplace, and hitting provider limits.\n\nFor almost every early product, a hosted processor like Stripe is enough, and building your own billing engine is a mistake you pay for twice. Buy first. What matters is knowing the specific moments a hosted processor quietly stops being enough, because they are predictable, and hitting one unprepared turns a planned migration into a scramble. The four that actually force the question: global tax, usage-based pricing, becoming a marketplace middleman, and the provider's own limits colliding with your product.\n\nI have built billing on top of hosted processors and I have built the layers they do not give you. The instinct to build early is almost always ego or a bad estimate. The instinct to keep buying forever is how you end up owing back taxes in a country you forgot you sold into. Both are avoidable if you know where the line is.\n\n## What \"buy\" actually gets you\n\nA payment service provider (Stripe, Braintree) handles the card network, [PCI scope](https://www.pcisecuritystandards.org/), storage of card data, retries, and a decent subscription model. That is a lot of genuinely hard, boring, regulated work you should not want to own. For a product with one or two plans, monthly and annual, in one or two countries, a hosted processor plus webhooks plus the [three habits I never skip](/blog/three-questions-before-i-write-billing-code) (source of truth, idempotency, audit log) is a complete billing system. Building your own here buys you nothing but PCI liability and a worse retry engine. The scarce resource at that stage is not a billing engine, it is [an engineer who treats that code as boring on purpose](/blog/the-first-hire-who-touches-your-billing-should-be-boring).\n\nSo the default is buy. The interesting question is the exits.\n\n## Quiet failure one: you owe tax everywhere\n\nThe moment you sell to customers in many countries, you inherit tax obligations you probably did not model. VAT in the EU, GST elsewhere, US sales tax that now applies to software in a growing list of states. A plain PSP like Stripe processes the payment but does not become the seller of record. You are the seller, which means you are the one who owes the filings.\n\nThis is where a merchant of record changes the math. A merchant of record (Paddle, Lemon Squeezy, and similar) is legally the seller. They collect and remit the tax, they take on the compliance, and you get one payout and one relationship instead of forty tax registrations. The tradeoff is a higher cut and less control over the checkout and the customer data. For a small team selling globally, that cut is usually cheaper than a part-time finance hire plus the risk of getting remittance wrong.\n\nThe trap is discovering this after you have crossed a threshold. Tax obligations do not wait for you to notice them.\n\n## Quiet failure two: your pricing outgrows flat plans\n\nHosted subscription billing is excellent at \"twenty nine dollars a month\". It gets awkward fast when pricing becomes usage-based, seat-based with [proration mid cycle](/blog/subscriptions-are-licensing-that-bills-on-time), or a hybrid of a base fee plus metered overage. You can bend a PSP's [metered billing](https://docs.stripe.com/billing) to do a lot, but at some point you are metering, aggregating, and rating usage in your own system and only using the provider to move the money.\n\nThat is the signal that you are already building billing, whether you admit it or not. The question stops being build vs buy and becomes which layer you own. Usually the right split is: you own metering and rating (because it is your product's logic and nobody else can do it), and you keep buying the payment rails, tax, and card handling. Owning the whole stack including the card vault is almost never worth it.\n\n## Quiet failure three: you become the middleman\n\nThe day you pay out to third parties (a marketplace, a platform where creators or vendors get a share) your billing problem changes shape. Now you have split payments, payout schedules, onboarding and identity verification for the people you pay, and a reconciliation problem in two directions. This is genuinely hard, and it is also exactly where the providers have built products ([Stripe Connect](https://docs.stripe.com/connect) and its equivalents, which cover split payments, payouts, and identity verification for the accounts you pay). Buy that too. Writing your own payout and KYC layer is a way to spend a year building a worse version of a regulated product.\n\n## Quiet failure four: the provider's limits become your product's limits\n\nSometimes the thing that forces a change is not scale, it is fit. The provider does not support a currency you need, a payment method your market actually uses, a dunning flow your customers expect, or a data residency requirement a large customer demands. When the processor starts dictating what your product can do, you either add a second processor and route between them, or you build an abstraction so the processor becomes swappable.\n\nThat abstraction is the one piece of \"build\" I recommend early, and it is cheap: do not let the vendor's SDK types leak into your domain. Wrap it.\n\n```php\ninterface PaymentGateway\n{\n    public function charge(Money $amount, Customer $customer, string $idempotencyKey): ChargeResult;\n    public function refund(string $chargeId, Money $amount): RefundResult;\n}\n```\n\nYou are not building a billing engine here. You are making sure that the day a second processor becomes necessary, it is a new implementation of an interface and not a rewrite of half your app. This costs a day now and saves a quarter later.\n\n## How to actually decide\n\nRun the decision against these, in order:\n\n1. One or two plans, one or two countries, cards only. Buy a PSP. Do not build. Do wrap it behind an interface.\n2. Selling globally, tax is now your problem. Strongly consider a merchant of record before you build a tax engine.\n3. Pricing is usage-based or hybrid. Own the metering and rating. Keep buying the rails.\n4. You pay out to third parties. Buy the marketplace product. Do not build KYC and payouts yourself.\n5. The provider cannot do something core to your product. Add a second processor behind your interface. Do not rewrite.\n\nNotice that \"build the whole thing\" never appears. In every early-stage team I have worked with, I have not met one that was right to build the card handling layer from scratch. The real skill is not building or buying. It is knowing which layer to own and buying everything under it, so that when you hit one of these quiet failures, you are extending a system instead of replacing one while customers are trying to pay you.\n\nBuying the rails does not outsource the judgment. Who decides to issue a refund, and [whether that decision should be automated at all](/blog/when-to-automate-refunds), stays yours either way.\n\n## The takeaway\n\nBuy the boring, regulated layers and own only the logic that is genuinely yours. The four quiet failures (global tax, usage-based pricing, becoming a marketplace, and provider limits) are predictable, so treat them as planned exits rather than emergencies. Do that, plus wrap your processor behind an interface on day one, and every one of these transitions becomes extending a system instead of replacing one while customers are trying to pay you.\n\n## FAQ\n\n**When is Stripe genuinely enough?**\nWhen you have one or two plans, monthly and annual, in one or two countries, cards only. A hosted processor plus webhooks plus source of truth, idempotency, and an audit log is a complete billing system at that stage.\n\n**What is the difference between a payment service provider and a merchant of record?**\nA PSP like Stripe processes the payment but leaves you as the seller of record who owes the tax filings. A merchant of record like Paddle or Lemon Squeezy is legally the seller, so they collect and remit tax in exchange for a higher cut and less control.\n\n**Should I ever build my own card handling?**\nAlmost never. It buys you PCI liability and a worse retry engine, and \"build the whole thing\" does not appear anywhere in a sane decision path.\n\n**What is the one piece of build worth doing early?**\nWrap the provider behind your own payment interface so its SDK types do not leak into your domain. It costs about a day and saves a quarter when a second processor becomes necessary.\n\n**How do I know I am already building billing without admitting it?**\nWhen you are metering, aggregating, and rating usage in your own system and only using the provider to move the money. At that point the question is which layer you own, not build vs buy.\n",[419,422,425,428,431],{"q":420,"a":421},"When is Stripe genuinely enough?","When you have one or two plans, monthly and annual, in one or two countries, cards only. A hosted processor plus webhooks plus source of truth, idempotency, and an audit log is a complete billing system at that stage.",{"q":423,"a":424},"What is the difference between a payment service provider and a merchant of record?","A PSP like Stripe processes the payment but leaves you as the seller of record who owes the tax filings. A merchant of record like Paddle or Lemon Squeezy is legally the seller, so they collect and remit tax in exchange for a higher cut and less control.",{"q":426,"a":427},"Should I ever build my own card handling?","Almost never. It buys you PCI liability and a worse retry engine, and \"build the whole thing\" does not appear anywhere in a sane decision path.",{"q":429,"a":430},"What is the one piece of build worth doing early?","Wrap the provider behind your own payment interface so its SDK types do not leak into your domain. It costs about a day and saves a quarter when a second processor becomes necessary.",{"q":432,"a":433},"How do I know I am already building billing without admitting it?","When you are metering, aggregating, and rating usage in your own system and only using the provider to move the money. At that point the question is which layer you own, not build vs buy.",{"slug":435,"title":436,"description":437,"date":438,"updated":177,"tags":439,"pillar":16,"draft":17,"image":18,"imageAlt":19,"markdown":444,"faq":445,"readingTimeText":464,"readingTimeMinutes":465},"why-i-run-uptime-checks-at-the-edge","Why I run uptime checks at the edge, not one server","A single VPS in one region tells you about its own path, not your users'. Here is how I built edge uptime checks on Cloudflare Workers and why.","2026-04-29T00:00:00.000Z",[440,441,442,443,287],"uptime-monitoring","cloudflare-workers","edge","architecture","\n## TL;DR\n\nUptime checks belong on the edge as scheduled Workers, not on a daemon in one region, because a single probe reveals its own path rather than your users'. The edge removes the babysat box, but true multi-vantage checking is deliberate design on top.\n\nI run my uptime monitoring platform as Cloudflare Workers on a cron trigger, not as a daemon on a box in us-east-1. The reason is simple: one server in one region does not tell you whether your site is down, it tells you whether that server's path to your site is down. Those are different questions, and conflating them produces the two failures every monitoring tool is judged on: false alarms at 2am and missed real outages. Moving the checker to the edge changes the economics and the correctness at the same time. Here is the actual design.\n\n## The problem with one probe\n\nA monitor that runs from a single region has a blind spot the size of that region. If your one probe sits in us-east-1 and us-east-1 has a bad afternoon, every site you watch goes \"down\" at once, and you page a hundred customers about an outage that is yours, not theirs. The inverse is worse: a routing problem between your probe and a target makes a perfectly healthy site look dead, and now the customer stops trusting your alerts, which is the only thing a monitoring product sells.\n\nYou also inherit an always-on server to babysit. It needs patching, it has a fixed cost whether it checks ten monitors or ten thousand, and when it dies your monitoring dies silently, which is the one component that must never fail quietly.\n\n## Why Workers, concretely\n\nCloudflare Workers on a [cron trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) removes the box. My checker is a scheduled Worker that fires every minute:\n\n```toml\n# wrangler.toml\n[triggers]\ncrons = [\"* * * * *\", \"5 0 * * *\"]\n```\n\nThe first schedule runs the checks every minute. The second (`5 0 * * *`, just after midnight) runs daily rollups and cleanup, and only on one shard so the heavy job runs once, not once per shard. There is no server to keep alive. Cloudflare invokes the Worker; if a given invocation fails, the next one is sixty seconds away. The thing that watches everyone else does not have a single always-on point of failure that I have to monitor with yet another tool.\n\nThe cost model flips too. An always-on VPS bills for time it sits idle. Workers bill per invocation and per unit of work, so a minute where nothing is due to be checked costs almost nothing. That is the right shape for a workload that is bursty by nature: most monitors are not due most minutes.\n\n## Sharding, because a minute is a hard deadline\n\nHere is a constraint people miss. If checks run every minute, every batch has to finish inside a minute, including retries and database writes. As the number of monitors grows, one Worker invocation cannot fan out to all of them and still land in time. So the checker is sharded, and the shard math lives in the query that selects work:\n\n```js\nconst shard = parseInt(env.SHARD || '0')\nconst totalShards = parseInt(env.TOTAL_SHARDS || '1')\n\n// each shard owns a disjoint slice of monitors by id\n// ... WHERE m.id % ? = ?  bound to (totalShards, shard)\n```\n\nEach deployed Worker owns `m.id % totalShards == shard`, a disjoint slice of monitors, so two shards never check the same monitor and together they cover all of them. Adding capacity is deploying another shard and bumping `TOTAL_SHARDS`, not resizing a box. The slice is deterministic, so a monitor always lands on the same shard, which keeps its history coherent.\n\n## Fan-out with a hard timeout on every check\n\nInside a shard, checks run concurrently, and each one has its own abort timeout so a single slow target cannot eat the whole minute:\n\n```js\nconst controller = new AbortController()\nconst timer = setTimeout(() => controller.abort(), monitor.timeout * 1000)\ntry {\n  const response = await fetch(monitor.url, { signal: controller.signal, redirect: 'follow' })\n  clearTimeout(timer)\n  // status compared against monitor.expected_status\n} catch (err) {\n  clearTimeout(timer)\n  // AbortError becomes 'Request timeout', everything else keeps its message\n}\n```\n\nThe whole batch is dispatched with [`Promise.allSettled`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled), not `Promise.all`, on purpose. One monitor throwing must not sink the results for every other monitor in the shard. `allSettled` gives me every outcome, good and bad, and I record them all. `all` would reject on the first failure and throw away the work already done, which in a monitoring system means losing the very signal you exist to capture.\n\n## Not every blip is an outage: confirm before you page\n\nThe edge buys location, but correctness still needs discipline in the logic, and this is where most homegrown monitors alarm too eagerly. A single failed request is not an incident. Networks hiccup. So there are two gates before anyone gets paged.\n\nFirst, an immediate retry when a check comes back down:\n\n```js\nlet result = await doFetch(monitor, headers)\nif (result.status === 'down') {\n  await new Promise(r => setTimeout(r, 2000)) // wait 2s, then look again\n  const retry = await doFetch(monitor, headers)\n  if (retry.status === 'up') result = retry\n}\n```\n\nSecond, an incident only opens when the current check is down and the previous stored check was also down. One down check is noise; two in a row is a signal. A recovery (an `up` check while an incident is open) resolves it and notifies once. That two-strikes rule, plus the inline retry, is what keeps the platform from crying wolf on a single dropped packet. It is the same instinct that keeps [payment retries from stampeding a struggling gateway](/blog/six-payments-edge-cases-that-bite-at-2am): confirm, back off, and do not react to a single blip.\n\nA monitor spends its credibility every time it pages on nothing, and it never fully earns that trust back. So I would rather lose the first few seconds of a real outage to a confirmation step than wake someone for a single dropped packet, because an alert nobody believes is worse than no alert at all.\n\n## The database has to be near the checker too\n\nIf the checker is at the edge but the database is a single Postgres box in one region, you have just moved the single point of failure and added latency to every write. Each check writes a row, and there are a lot of checks per minute. So the store is [Cloudflare D1](https://developers.cloudflare.com/d1/) (SQLite at the edge), bound straight into the Worker. Writes are batched in chunks so a shard with many results does not fire hundreds of individual statements inside its one-minute window:\n\n```js\nfor (let i = 0; i \u003C batch.length; i += 100) {\n  await db.batch(batch.slice(i, i + 100))\n}\n```\n\nKeeping compute and storage in the same platform is not a purity thing. It is what makes the per-minute deadline achievable at all.\n\n## What the edge does not magically give you\n\nI will be honest about the limit, because overclaiming is how monitoring tools lose trust. Running as a Worker means checks originate from Cloudflare's network rather than a fixed VPS, and it removes the babysat server. It does not, by itself, guarantee that every check leaves from a different continent every minute; Cloudflare decides where a cron invocation runs. True multi-vantage checking is a deliberate design on top of this (invoking from chosen locations and reconciling their verdicts), not a free side effect of the word \"edge.\" What the architecture does guarantee is that I am not betting my customers' trust on the health of one region and one always-on machine.\n\nThat is the trade I wanted. No server to keep breathing, a per-minute deadline I can actually hit by sharding, a store that lives next to the compute, and alerting logic that confirms before it wakes anyone. A monitoring tool earns its keep on the night it stays quiet because it correctly decided a single dropped packet was not the end of the world. That quiet is what [boring, durable code buys you](/blog/boring-code-is-a-feature-you-ship-to-your-future-self): no drama, no heroics, just a system that keeps deciding correctly while you sleep.\n\n## The takeaway\n\nMoving the checker to the edge is not about the word edge, it is about removing the two things that make a single-region monitor lie: a blind spot the size of one region and an always-on box that fails silently. Workers give me a per-invocation cost model, sharding that hits the one-minute deadline, storage in D1 next to the compute, and a confirm-before-you-page rule that keeps the alerts trustworthy. I still owe the deliberate work of true multi-vantage checking, but I am no longer betting my customers' trust on the health of one region and one machine.\n\n## FAQ\n\n**Why not just run the monitor from one VPS in us-east-1?**\nBecause a probe in one region only tells you whether that region's path to your site is healthy, so a bad afternoon in us-east-1 pages every customer at once, and a routing problem makes a healthy site look dead. Both failures destroy the trust that is the only thing a monitoring product sells.\n\n**Why Cloudflare Workers on a cron trigger instead of a daemon?**\nWorkers remove the always-on server you have to patch and babysit, and they bill per invocation instead of for idle time, which fits a bursty workload where most monitors are not due most minutes. There is no single always-on point of failure watching everyone else.\n\n**Why shard the checker?**\nBecause checks run every minute and each batch, including retries and writes, must finish inside that minute. One invocation cannot fan out to every monitor as the count grows, so each shard owns a disjoint slice by monitor id, and adding capacity is deploying another shard, not resizing a box.\n\n**Why Promise.allSettled instead of Promise.all for the fan-out?**\nBecause one monitor throwing must not sink the results for every other monitor in the shard. allSettled records every outcome, good and bad, while all rejects on the first failure and throws away work already done, which in monitoring means losing the signal you exist to capture.\n\n**How do you avoid paging on a single dropped packet?**\nTwo gates: an inline retry two seconds after a down result, and an incident that only opens when the current check and the previous stored check were both down. One down check is noise; two in a row is a signal, and a recovery resolves and notifies once.\n\n**Does running at the edge guarantee multi-region checking?**\nNo, and claiming it does is how monitoring tools lose trust. Cloudflare decides where a cron invocation runs, so true multi-vantage checking means deliberately invoking from chosen locations and reconciling their verdicts, which is a design you build on top of this, not a free side effect.\n",[446,449,452,455,458,461],{"q":447,"a":448},"Why not just run the monitor from one VPS in us-east-1?","Because a probe in one region only tells you whether that region's path to your site is healthy, so a bad afternoon in us-east-1 pages every customer at once, and a routing problem makes a healthy site look dead. Both failures destroy the trust that is the only thing a monitoring product sells.",{"q":450,"a":451},"Why Cloudflare Workers on a cron trigger instead of a daemon?","Workers remove the always-on server you have to patch and babysit, and they bill per invocation instead of for idle time, which fits a bursty workload where most monitors are not due most minutes. There is no single always-on point of failure watching everyone else.",{"q":453,"a":454},"Why shard the checker?","Because checks run every minute and each batch, including retries and writes, must finish inside that minute. One invocation cannot fan out to every monitor as the count grows, so each shard owns a disjoint slice by monitor id, and adding capacity is deploying another shard, not resizing a box.",{"q":456,"a":457},"Why Promise.allSettled instead of Promise.all for the fan-out?","Because one monitor throwing must not sink the results for every other monitor in the shard. allSettled records every outcome, good and bad, while all rejects on the first failure and throws away work already done, which in monitoring means losing the signal you exist to capture.",{"q":459,"a":460},"How do you avoid paging on a single dropped packet?","Two gates: an inline retry two seconds after a down result, and an incident that only opens when the current check and the previous stored check were both down. One down check is noise; two in a row is a signal, and a recovery resolves and notifies once.",{"q":462,"a":463},"Does running at the edge guarantee multi-region checking?","No, and claiming it does is how monitoring tools lose trust. Cloudflare decides where a cron invocation runs, so true multi-vantage checking means deliberately invoking from chosen locations and reconciling their verdicts, which is a design you build on top of this, not a free side effect.","10 min read",10,{"slug":467,"title":468,"description":469,"date":470,"updated":177,"tags":471,"pillar":211,"draft":17,"image":212,"imageAlt":213,"markdown":474,"faq":475,"readingTimeText":138,"readingTimeMinutes":139},"supporting-a-plugin-across-wordpress-versions","Supporting a plugin across WordPress versions you don't own","A compatibility strategy for WordPress and PHP versions you don't control: function_exists and capability checks, graceful degradation, a support matrix.","2026-04-22T00:00:00.000Z",[54,52,472,208,473],"compatibility","support","\n## TL;DR\n\nYou cannot control the WordPress and PHP versions your plugin lands on. Detect capabilities at runtime with function_exists and current_user_can, degrade gracefully when something is missing, and publish a support matrix with a hard floor enforced at activation.\n\nThe hardest constraint in plugin work is that you ship one codebase to environments you do not control and cannot see. My affiliate, licensing, and link plugins run on WordPress installs from current down to versions years old, on PHP from modern down to whatever the cheap host froze on, next to a thousand other plugins that also monkey with the same hooks. I cannot pin any of it. That same lack of control is why I [never rewrite one of these plugins in a single big push](/blog/you-dont-rewrite-a-plugin-with-a-big-install-base), because the install base is too varied to bet on. So the strategy is not \"require the latest,\" it is: detect what exists at runtime, degrade gracefully when it does not, and publish a support matrix so both I and the user know where the line is.\n\nHere is how each of those three pieces works in practice.\n\n## Detect capabilities at runtime, do not assume versions\n\nChecking `$wp_version` to decide whether a feature is available is the wrong instinct. The version string tells you what someone claims to run, not what functions and classes are actually loaded, and plenty of setups backport or strip things. The reliable question is not \"which version is this\" but \"does the thing I need exist right now.\" So I ask directly. [`register_block_bindings_source()`](https://developer.wordpress.org/reference/functions/register_block_bindings_source/) was introduced in WordPress 6.5, but the only thing that tells me it is actually loaded on this install is asking for the function.\n\n```php\nfunction affapp_can_use_block_bindings() {\n    // Introduced in WP 6.5. Ask for the function, not the version.\n    return function_exists( 'register_block_bindings_source' );\n}\n\nif ( affapp_can_use_block_bindings() ) {\n    register_block_bindings_source( 'affapp/referral-url', array(\n        'label'              => __( 'Referral URL', 'affapp' ),\n        'get_value_callback' => 'affapp_referral_binding_value',\n    ) );\n}\n```\n\nThe same discipline covers PHP itself. Before I use a language feature or standard-library function that is not universally present, such as [`str_contains()`, which arrived in PHP 8.0](https://www.php.net/manual/en/function.str-contains.php), I check for it rather than assume the interpreter has it.\n\n```php\n// str_contains is PHP 8+. Some hosts are still on 7.4.\nif ( ! function_exists( 'str_contains' ) ) {\n    function str_contains( $haystack, $needle ) {\n        return '' === $needle || false !== strpos( $haystack, $needle );\n    }\n}\n```\n\n`function_exists`, `class_exists`, and `method_exists` are how I write code that is honest about its environment. It asks the room what is present and acts on the answer, instead of trusting a version number that may be lying or backported.\n\n## Capability checks, not role name checks\n\nThe same \"ask, do not assume\" rule applies to permissions. WordPress lets site owners, membership plugins, and other code [remap which capabilities a role carries](https://wordpress.org/documentation/article/roles-and-capabilities/). Checking whether the current user is literally an `administrator` is fragile, because a shop might grant an \"affiliate manager\" role the exact capabilities without giving it that role name. So I check the capability, and I register my own where a built-in one does not fit.\n\n```php\n// Wrong: assumes the role name and the capability are the same thing.\nif ( in_array( 'administrator', wp_get_current_user()->roles, true ) ) { }\n\n// Right: ask whether this user can do this specific thing.\nif ( current_user_can( 'manage_affiliates' ) ) {\n    affapp_render_admin_panel();\n}\n```\n\nI add `manage_affiliates` to the roles I ship for on activation, and site owners can grant it wherever they like. The plugin never asks \"who are you,\" it asks \"are you allowed to do this,\" which is the only question that survives contact with a real site's permission setup.\n\n## Graceful degradation over hard requirements\n\nWhen a capability is missing, the plugin should do less, not die. A hard `wp_die()` on an old PHP version turns one unsupported host into a support ticket and a one-star review. Degrading means the core feature still works and the enhancement is simply absent, ideally with a quiet notice so the site owner knows why.\n\n```php\nadd_action( 'admin_init', function () {\n    if ( version_compare( PHP_VERSION, '7.4', '\u003C' ) ) {\n        // Do not fatal. Disable the feature that needs it, tell the owner.\n        add_option( 'affapp_advanced_reports', 0 );\n        add_action( 'admin_notices', 'affapp_php_notice' );\n    }\n} );\n\nfunction affapp_php_notice() {\n    echo '\u003Cdiv class=\"notice notice-warning\">\u003Cp>';\n    echo esc_html__(\n        'Advanced reports need PHP 7.4 or newer. The rest of the plugin works normally.',\n        'affapp'\n    );\n    echo '\u003C/p>\u003C/div>';\n}\n```\n\nThe reason this matters more on a plugin than an app is that the user did not choose their PHP version for you. They are on a host, the host set the version, and moving hosts is a project. My job is to keep the core commission tracking working on their old box while the shiny report is politely unavailable, not to hold their whole plugin hostage to a version bump they cannot make this week. This is the same instinct I bring to [shipping a feature into a five-year-old plugin](/blog/shipping-a-feature-into-a-five-year-old-plugin), where the new capability arrives behind a check and the old path keeps working untouched.\n\nThere is a floor, though. Below some version I will not test against, degrading turns into pretending. That is what the matrix is for.\n\n## Publish a support matrix so the line is explicit\n\nA support matrix is a written statement of which combinations I test and stand behind, which I tolerate, and which I refuse. It protects the user from silent breakage and protects me from being on the hook for a PHP 5.6 box I have not run in years. I keep it in the readme and in my head, and I enforce the hard floor in code.\n\n| WordPress | PHP | Status |\n|-----------|-----|--------|\n| Current and one back | 8.1+ | Fully tested, all features |\n| Two to four back | 7.4 to 8.0 | Supported, advanced features may degrade |\n| Older than that | 7.4+ | Best effort, core only, no guarantees |\n| Any | Below 7.4 | Not supported, activation blocked |\n\nThe bottom row is enforced, not aspirational. I check it in the main plugin file before anything else loads, and I fail the activation cleanly rather than half-loading into a fatal.\n\n```php\nregister_activation_hook( __FILE__, function () {\n    if ( version_compare( PHP_VERSION, '7.4', '\u003C' ) ) {\n        deactivate_plugins( plugin_basename( __FILE__ ) );\n        wp_die(\n            esc_html__( 'This plugin requires PHP 7.4 or newer.', 'affapp' ),\n            esc_html__( 'Plugin activation error', 'affapp' ),\n            array( 'back_link' => true )\n        );\n    }\n} );\n```\n\nBlocking activation on the hard floor is kinder than letting it install and fatal later, because the user gets a clear message at the moment they act, with a back link, instead of a white screen after the fact.\n\nThe matrix is not static. When a new WordPress or PHP release changes behavior I depend on, I update the matrix before I touch the code, adjust the runtime checks, and, when the change reaches stored data, roll it out the same careful way I handle [backward-compatible migrations on an install base](/blog/backward-compatible-migrations-on-an-install-base).\n\n## The strategy in one line\n\nAsk the environment what it can do, do less when it can do less, and write down where you stop. `function_exists` and `current_user_can` over version and role assumptions. A notice and a disabled feature over a fatal. A published matrix with an enforced floor over silent hope. I cannot control the WordPress and PHP versions my plugins land on, and I stopped trying. The whole compatibility strategy is accepting that I do not control the room, and writing code that behaves well no matter which room it wakes up in.\n\n## The takeaway\n\nCompatibility across versions you cannot see comes down to honesty about the environment. Ask what functions, classes, and capabilities are actually present, do less when the answer is less, and block activation cleanly below the floor you are willing to test. A runtime check beats a version assumption, a disabled feature with a notice beats a white screen, and a published matrix with an enforced floor beats silent hope on every host your plugin wakes up on.\n\n## FAQ\n\n**Should I check $wp_version to decide whether a feature is available?**\nNo. Ask whether the function or class you need exists with function_exists or class_exists, because the version string tells you what someone claims to run, not what is actually loaded, and plenty of setups backport or strip things.\n\n**Why check capabilities instead of role names?**\nBecause site owners and membership plugins remap what roles can do. current_user_can('manage_affiliates') survives that setup, while checking whether the user is literally an administrator breaks when a shop grants the same capabilities to a differently named role.\n\n**What should happen when a host is on an unsupported PHP version?**\nDegrade, do not die. Disable the feature that needs it, keep the core commission tracking working, and show a quiet admin notice, rather than calling wp_die and turning one old host into a support ticket and a one-star review.\n\n**Is there ever a case for blocking activation outright?**\nYes, at the hard floor below which degrading turns into pretending. Check it in the main plugin file and fail activation cleanly with a clear message and a back link, which is kinder than half-loading into a fatal after install.\n\n**What belongs in a support matrix?**\nWhich WordPress and PHP combinations you fully test and stand behind, which you tolerate with features that may degrade, and which you refuse. Keep it in the readme and enforce the bottom row in code.\n",[476,479,482,485,488],{"q":477,"a":478},"Should I check $wp_version to decide whether a feature is available?","No. Ask whether the function or class you need exists with function_exists or class_exists, because the version string tells you what someone claims to run, not what is actually loaded, and plenty of setups backport or strip things.",{"q":480,"a":481},"Why check capabilities instead of role names?","Because site owners and membership plugins remap what roles can do. current_user_can('manage_affiliates') survives that setup, while checking whether the user is literally an administrator breaks when a shop grants the same capabilities to a differently named role.",{"q":483,"a":484},"What should happen when a host is on an unsupported PHP version?","Degrade, do not die. Disable the feature that needs it, keep the core commission tracking working, and show a quiet admin notice, rather than calling wp_die and turning one old host into a support ticket and a one-star review.",{"q":486,"a":487},"Is there ever a case for blocking activation outright?","Yes, at the hard floor below which degrading turns into pretending. Check it in the main plugin file and fail activation cleanly with a clear message and a back link, which is kinder than half-loading into a fatal after install.",{"q":489,"a":490},"What belongs in a support matrix?","Which WordPress and PHP combinations you fully test and stand behind, which you tolerate with features that may degrade, and which you refuse. Keep it in the readme and enforce the bottom row in code.",{"slug":492,"title":493,"description":494,"date":495,"updated":177,"tags":496,"pillar":85,"draft":17,"image":86,"imageAlt":87,"markdown":499,"faq":500,"readingTimeText":138,"readingTimeMinutes":139},"authentication-mistakes-in-early-products","Auth mistakes in early products, and how to fix each","The same auth bugs show up in early products: session fixation, weak hashing, no rate limiting, tokens in the wrong place. Here is the fix for each.","2026-04-15T00:00:00.000Z",[497,498,53,85],"authentication","security","\n## TL;DR\n\nEarly products keep making the same four auth mistakes: session fixation, weak password hashing, no login rate limiting, and tokens in localStorage. Each has a cheap, specific fix you can ship before your next deploy.\n\nThe authentication bugs in early products are almost always the same handful, and each has a concrete, cheap fix. Session fixation, weak password hashing, no rate limiting on login, and tokens stored somewhere JavaScript can read them. None of these are exotic attacks. All of them are an afternoon to fix before launch and a very bad week to fix after someone's account gets taken over. Here is each mistake and the exact fix I reach for, from building authentication into Laravel apps and the WordPress plugin ecosystem.\n\nI am not going to lecture about \"security is important\". You know that. The value here is specificity: the actual mistake, why it bites, and the two or three lines that close it.\n\n## Mistake one: session fixation\n\nThe bug: you assign a session ID before login and keep the same ID after login. An attacker who can set a victim's session ID (via a crafted link, a shared machine, a sibling subdomain) is then logged in as that victim the moment the victim authenticates, because you never changed the identifier that represents \"who is logged in\".\n\nThe fix is one line: regenerate the session ID at the moment the privilege level changes, which is login. In Laravel this is handled if you use the framework's auth, but people bypass it with custom login flows:\n\n```php\npublic function login(Request $request): RedirectResponse\n{\n    // ... validate credentials ...\n\n    $request->session()->regenerate(); // new ID now that identity changed\n    Auth::login($user);\n\n    return redirect()->intended('/dashboard');\n}\n```\n\nDo the same on logout by invalidating the session entirely. The rule is simple: any time the answer to \"who is this\" changes, the session ID changes with it, which is exactly what the [OWASP session management guidance](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) means when it says to renew the session ID on any privilege level change.\n\n## Mistake two: weak or fast password hashing\n\nThe bug: passwords stored with a fast hash (MD5, SHA1, a single SHA256) or, worse, in a form that can be reversed. WordPress historically leaned on phpass, which is far better than plain MD5 but is showing its age. A fast hash is a gift to anyone who gets a copy of your users table, because fast is exactly what a cracker wants: billions of guesses per second.\n\nThe fix is a slow, salted, adaptive hash built for passwords: bcrypt, or argon2id if you have it. Those are the two the [OWASP password storage guidance](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) puts at the top of its list. Laravel defaults to bcrypt and you should let it:\n\n```php\n// Good: adaptive, salted, deliberately slow.\n$hash = password_hash($plain, PASSWORD_BCRYPT, ['cost' => 12]);\n\n// Verify without ever reversing anything.\nif (password_verify($input, $hash)) {\n    // optionally: if password_needs_rehash(...), upgrade the stored hash here\n}\n```\n\n\"Deliberately slow\" is the feature. Tune the cost so a single verify takes a fraction of a second on your hardware. That fraction is invisible to your one legitimate user logging in and brutal to an attacker running a billion guesses. And salting is automatic with these functions, so nobody should be managing salts by hand in 2026. If you are moving an existing user base off a weak hash, rehash on the next successful login and treat it like any other [backward-compatible migration on an install base](/blog/backward-compatible-migrations-on-an-install-base).\n\n## Mistake three: no rate limiting on login\n\nThe bug: the login endpoint accepts unlimited attempts. That turns every account into a target for credential stuffing (replaying leaked password lists) and brute force. Strong hashing protects a stolen database. It does nothing to stop someone hammering your live login form with a million guesses.\n\nThe fix is a limiter keyed on both the account and the source, so one attacker cannot lock out everyone and one victim account cannot be pounded forever:\n\n```php\n$key = 'login:'.$request->ip().'|'.Str::lower($request->input('email'));\n\nif (RateLimiter::tooManyAttempts($key, maxAttempts: 5)) {\n    $seconds = RateLimiter::availableIn($key);\n    abort(429, \"Too many attempts. Try again in {$seconds}s.\");\n}\n\nRateLimiter::hit($key, decaySeconds: 60);\n// ... on success: RateLimiter::clear($key);\n```\n\nPair it with a generic error message. \"Invalid email or password\" leaks nothing. \"No account with that email\" tells an attacker which addresses are worth guessing against. Small wording, real information leak.\n\n## Mistake four: tokens in the wrong place\n\nThe bug: storing a session token or JWT in `localStorage` because it was easy. Anything in `localStorage` is readable by any JavaScript on the page, which means one cross-site scripting hole hands your token to an attacker, and a token is a bearer of identity: whoever holds it is you.\n\nThe fix depends on what you are building, but the default I reach for is a cookie with the right flags rather than `localStorage`:\n\n```\nSet-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/\n```\n\n`HttpOnly` keeps JavaScript from reading it, which defangs the XSS-steals-token attack, and it is a cookie attribute defined in [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) rather than a framework nicety. `Secure` keeps it off plaintext HTTP. `SameSite` blunts CSRF. If you genuinely need token-based auth for a separate frontend or mobile client, keep the tokens short lived, refresh them server side, and never treat a long-lived token sitting in browser storage as acceptable.\n\nThis is also the session vs token decision, and people pick token auth reflexively because it feels modern. For a classic web app served from one backend, server-side sessions with a hardened cookie are simpler and safer than hand-rolling JWT refresh logic. Reach for tokens when you actually have the problem they solve (a stateless API, multiple clients), not by default.\n\n## The pattern underneath all four\n\nEvery one of these is the same mistake wearing different clothes: trusting something you should not, or skipping a cheap control because the happy path worked in testing. Session fixation trusts an identifier across a privilege change. Weak hashing trusts that your database will never leak. No rate limiting trusts that only real users will hit your form. Tokens in `localStorage` trust that you will never ship an XSS bug. Every one of those trusts is eventually wrong.\n\nThe fixes are not clever and that is the point. Regenerate the session on login. Use bcrypt or argon2id. Rate limit the login endpoint by account and IP. Put the token in an `HttpOnly` cookie. You can close all four before your next deploy, and doing so quietly removes the most common ways early products get their users compromised. Authentication is not where you want to be original. It is where you want to be [boringly correct](/blog/boring-code-is-a-feature-you-ship-to-your-future-self), because the cost of being wrong is not yours to pay. It lands on the people who trusted you with a password.\n\n## The takeaway\n\nThese four bugs share one root: trusting something you should not, or skipping a cheap control because the happy path worked in testing. The fixes are not clever, and that is the point, because authentication is not where you want to be original. Close all four before your next deploy and you quietly remove the most common ways early products get their users compromised.\n\n## FAQ\n\n**If I use Laravel's built-in auth, do I still need to worry about these?**\nSession regeneration and bcrypt are handled for you if you stay on the framework's auth. The risk shows up when people bypass it with custom login flows, so the mistakes return the moment you hand-roll.\n\n**bcrypt or argon2id, which should I pick?**\nEither is fine; both are slow, salted, and adaptive. Laravel defaults to bcrypt and you should let it, tuning the cost so a single verify takes a fraction of a second.\n\n**Is JWT in localStorage always wrong?**\nFor a classic web app served from one backend, yes, prefer a hardened server-side session cookie. Reach for tokens only when you have the problem they solve, a stateless API or multiple clients, and even then keep them short lived and refreshed server side.\n\n**Why rate limit by both account and IP instead of just one?**\nSo one attacker cannot lock out every user and one victim account cannot be pounded forever. Keying on both the source and the account balances those two failure modes.\n\n**Does strong password hashing remove the need for rate limiting?**\nNo. Hashing protects a stolen database; it does nothing to stop someone hammering your live login form with credential stuffing. You need both.\n",[501,504,507,510,513],{"q":502,"a":503},"If I use Laravel's built-in auth, do I still need to worry about these?","Session regeneration and bcrypt are handled for you if you stay on the framework's auth. The risk shows up when people bypass it with custom login flows, so the mistakes return the moment you hand-roll.",{"q":505,"a":506},"bcrypt or argon2id, which should I pick?","Either is fine; both are slow, salted, and adaptive. Laravel defaults to bcrypt and you should let it, tuning the cost so a single verify takes a fraction of a second.",{"q":508,"a":509},"Is JWT in localStorage always wrong?","For a classic web app served from one backend, yes, prefer a hardened server-side session cookie. Reach for tokens only when you have the problem they solve, a stateless API or multiple clients, and even then keep them short lived and refreshed server side.",{"q":511,"a":512},"Why rate limit by both account and IP instead of just one?","So one attacker cannot lock out every user and one victim account cannot be pounded forever. Keying on both the source and the account balances those two failure modes.",{"q":514,"a":515},"Does strong password hashing remove the need for rate limiting?","No. Hashing protects a stolen database; it does nothing to stop someone hammering your live login form with credential stuffing. You need both.",1787064931947]