[{"data":1,"prerenderedAt":424},["ShallowReactive",2],{"blog-posts":3},[4,38,70,102,135,160,188,216,241,265,293,317,342,374,399],{"slug":5,"title":6,"description":7,"date":8,"updated":9,"tags":10,"pillar":15,"draft":16,"image":17,"imageAlt":18,"markdown":19,"faq":20,"readingTimeText":36,"readingTimeMinutes":37},"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",null,[11,12,13,14],"strategy","teams","engineering-leadership","long-term","founder-engineering",false,"/blog-covers/founder-engineering.png","Founder engineering cover","\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",[21,24,27,30,33],{"q":22,"a":23},"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":25,"a":26},"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":28,"a":29},"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":31,"a":32},"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":34,"a":35},"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":39,"title":40,"description":41,"date":42,"updated":9,"tags":43,"pillar":48,"draft":16,"image":49,"imageAlt":50,"markdown":51,"faq":52,"readingTimeText":68,"readingTimeMinutes":69},"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",[44,45,46,47],"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",[53,56,59,62,65],{"q":54,"a":55},"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":57,"a":58},"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":60,"a":61},"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":63,"a":64},"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":66,"a":67},"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":71,"title":72,"description":73,"date":74,"updated":9,"tags":75,"pillar":48,"draft":16,"image":49,"imageAlt":50,"markdown":80,"faq":81,"readingTimeText":100,"readingTimeMinutes":101},"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",[44,76,77,78,79],"refunds","laravel","code-review","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. 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 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. 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\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",[82,85,88,91,94,97],{"q":83,"a":84},"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":86,"a":87},"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":89,"a":90},"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":92,"a":93},"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":95,"a":96},"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":98,"a":99},"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":103,"title":104,"description":105,"date":106,"updated":9,"tags":107,"pillar":113,"draft":16,"image":114,"imageAlt":115,"markdown":116,"faq":117,"readingTimeText":133,"readingTimeMinutes":134},"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",[108,109,110,111,112],"wordpress","plugins","refactoring","migrations","php","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. 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`, 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()`, 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",[118,121,124,127,130],{"q":119,"a":120},"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":122,"a":123},"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":125,"a":126},"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":128,"a":129},"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":131,"a":132},"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":136,"title":137,"description":138,"date":139,"updated":9,"tags":140,"pillar":15,"draft":16,"image":17,"imageAlt":18,"markdown":143,"faq":144,"readingTimeText":133,"readingTimeMinutes":134},"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",[141,142,15],"billing","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.\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, 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 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. 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",[145,148,151,154,157],{"q":146,"a":147},"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":149,"a":150},"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":152,"a":153},"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":155,"a":156},"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":158,"a":159},"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":161,"title":162,"description":163,"date":164,"updated":9,"tags":165,"pillar":168,"draft":16,"image":169,"imageAlt":170,"markdown":171,"faq":172,"readingTimeText":36,"readingTimeMinutes":37},"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",[166,167,78,168],"code-quality","maintainability","products-that-last","/blog-covers/products-that-last.png","Products that last cover","\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) 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. 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. 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",[173,176,179,182,185],{"q":174,"a":175},"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":177,"a":178},"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":180,"a":181},"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":183,"a":184},"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":186,"a":187},"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":189,"title":190,"description":191,"date":192,"updated":9,"tags":193,"pillar":48,"draft":16,"image":49,"imageAlt":50,"markdown":196,"faq":197,"readingTimeText":100,"readingTimeMinutes":101},"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",[44,79,194,77,195],"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. 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, 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; 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\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",[198,201,204,207,210,213],{"q":199,"a":200},"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":202,"a":203},"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":205,"a":206},"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":208,"a":209},"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":211,"a":212},"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":214,"a":215},"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":217,"title":218,"description":219,"date":220,"updated":9,"tags":221,"pillar":113,"draft":16,"image":114,"imageAlt":115,"markdown":224,"faq":225,"readingTimeText":36,"readingTimeMinutes":37},"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",[108,109,222,223,112],"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.\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 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` 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",[226,229,232,235,238],{"q":227,"a":228},"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":230,"a":231},"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":233,"a":234},"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":236,"a":237},"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":239,"a":240},"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":242,"title":243,"description":244,"date":245,"updated":9,"tags":246,"pillar":15,"draft":16,"image":17,"imageAlt":18,"markdown":248,"faq":249,"readingTimeText":133,"readingTimeMinutes":134},"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",[141,247,15],"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. 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 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 transaction 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\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",[250,253,256,259,262],{"q":251,"a":252},"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":254,"a":255},"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":257,"a":258},"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":260,"a":261},"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":263,"a":264},"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":266,"title":267,"description":268,"date":269,"updated":9,"tags":270,"pillar":48,"draft":16,"image":49,"imageAlt":50,"markdown":273,"faq":274,"readingTimeText":100,"readingTimeMinutes":101},"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",[247,271,141,272,77],"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 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. 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. 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",[275,278,281,284,287,290],{"q":276,"a":277},"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":279,"a":280},"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":282,"a":283},"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":285,"a":286},"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":288,"a":289},"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":291,"a":292},"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":294,"title":295,"description":296,"date":297,"updated":9,"tags":298,"pillar":113,"draft":16,"image":114,"imageAlt":115,"markdown":300,"faq":301,"readingTimeText":100,"readingTimeMinutes":101},"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",[108,111,299,46,112],"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.\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()` 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` 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",[302,305,308,311,314],{"q":303,"a":304},"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":306,"a":307},"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":309,"a":310},"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":312,"a":313},"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":315,"a":316},"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":318,"title":319,"description":320,"date":321,"updated":9,"tags":322,"pillar":15,"draft":16,"image":17,"imageAlt":18,"markdown":325,"faq":326,"readingTimeText":36,"readingTimeMinutes":37},"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",[141,323,324,15],"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, 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 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 and its equivalents). 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\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",[327,330,333,336,339],{"q":328,"a":329},"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":331,"a":332},"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":334,"a":335},"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":337,"a":338},"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":340,"a":341},"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":343,"title":344,"description":345,"date":346,"updated":9,"tags":347,"pillar":168,"draft":16,"image":169,"imageAlt":170,"markdown":352,"faq":353,"readingTimeText":372,"readingTimeMinutes":373},"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",[348,349,350,351,195],"uptime-monitoring","cloudflare-workers","edge","architecture","\n## TL;DR\n\nI run uptime checks as Cloudflare Workers on a cron, not a daemon in one region, because one probe only reveals its own path, not your users'. The edge kills 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 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`, 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 (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",[354,357,360,363,366,369],{"q":355,"a":356},"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":358,"a":359},"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":361,"a":362},"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":364,"a":365},"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":367,"a":368},"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":370,"a":371},"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":375,"title":376,"description":377,"date":378,"updated":9,"tags":379,"pillar":113,"draft":16,"image":114,"imageAlt":115,"markdown":382,"faq":383,"readingTimeText":36,"readingTimeMinutes":37},"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",[108,112,380,109,381],"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.\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, 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 what roles can do. 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",[384,387,390,393,396],{"q":385,"a":386},"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":388,"a":389},"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":391,"a":392},"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":394,"a":395},"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":397,"a":398},"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":400,"title":401,"description":402,"date":403,"updated":9,"tags":404,"pillar":15,"draft":16,"image":17,"imageAlt":18,"markdown":407,"faq":408,"readingTimeText":133,"readingTimeMinutes":134},"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",[405,406,77,15],"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.\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. 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. `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",[409,412,415,418,421],{"q":410,"a":411},"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":413,"a":414},"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":416,"a":417},"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":419,"a":420},"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":422,"a":423},"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.",1785090218575]