Fifteen pull requests at 3cat in December, every one of them merged. Thirty-six contributions. On the Discord bot, after a hundred and twenty-one commits in November: one.
Two things in this month matter more than the rest. One is a feature that finally put a fourteen-month-old mistake of mine in the right place. The other is the last pull request I merged in 2025.
The last hotfix of the year
On 30 December I merged a hotfix titled Update db & admin credentials. Six files, fifty-two additions.
Three of them are environment files — production, staging and QA — each with a single line changed. The other three are Bagisto's own installer: its controller, its route file, and the middleware that decides whether the installer is allowed to run at all. Every added block in that diff carries a comment beginning SECURITY FIX.
The problem was that the installer was still reachable on a shop that had been live for two years. The guard meant to switch it off after installation had a gap in it, and the installer's own API routes had been declared in a way that opted them out of the framework's request protections. Those endpoints are not trivial ones — the installer exists to write environment configuration, run migrations, seed data and create the first administrator.
I am not going to lay out the exact shape of it here, because this is upstream code that other people are running. What matters for this account is the sequence: the guard was tightened so it refuses every request once the application is installed rather than only some of them, the route definitions were brought back under the standard protections, and the same hotfix rotated the database and admin credentials in all three environments. You rotate credentials when you cannot prove nothing reached them.
The part I keep coming back to is where the code lived. In July 2024 I wrote about how this application does not depend on Bagisto — it is Bagisto, with the framework's own packages/Webkul/ tree committed into the repository, which is why an upgrade means resolving fourteen hundred files by hand. I described that as a cost. In December it was the only reason this was fixable in an afternoon: the installer was our file to edit.
The other half of that thought is less comfortable. Vendoring the framework means every one of its defaults is now yours, including the ones you never read. This installer had been sitting in the repository since the first week, through two version upgrades I personally performed, and I had never once opened its middleware.
Vouchers learned to stack
The month's largest feature, and the largest single ticket I have taken on here: 6,033 additions and 3,744 deletions across sixteen files. Most of that is lock files — the hand-written part is closer to seventeen hundred lines, which is still the biggest thing in this series that is not a framework upgrade.
We want to enable stackable vouchers at checkout
1) Not all vouchers are stackable. Only vouchers with a 'stackable = true'
voucher can be added onto other vouchers.
6) The prices (summary section, split payment, instalment prices) should
all be recalculated when a voucher or additional voucher is applied.
Test Scenario: Stackable vouchers can be delivery specific OR
paid-reservation specific OR both.Read that last line with two years of context. A voucher can already be conditional on choosing delivery, or on a paid reservation. Now two of them can apply at once, each with its own conditions, and every price on the page — subtotal, split payment halves, instalment monthly figures — has to agree afterwards.
This is the most collision-prone code in the application and I have the receipts. October 2024: I built voucher validation by hand-reading the cart rule engine's stored conditions, and wrote that it would drift. December 2024: it turned out to be ignoring its own operators, so exclusions applied backwards. April 2025: a colleague's fulfilment feature broke vouchers on pickup orders and I reverted the whole thing. July 2025: a telco partnership arrived as a coupon code and needed six production fixes in five days.
Walking into that deliberately, to make it do more, is the right call and it was not a comfortable one.
Fourteen months to the right place
The reason I would defend it is a new file of 274 lines:
namespace P3cat\CartRule\Helpers;
use Webkul\CartRule\Helpers\CartRule as CoreCartRule;
class CartRule extends CoreCartRule
{
public function canProcessRule($rule): bool
{That is the correction. In October 2024 I needed to know why a voucher had been rejected, the framework's engine only told me whether, and so I wrote a partial reimplementation of its condition evaluation in a controller — reading its stored condition format, handling one operator out of several, and shipping it as though it handled all of them.
Fourteen months later the answer is to extend the engine and override the method that decides whether a rule applies. Same problem, same requirement, inside the framework's own class instead of alongside it. Everything the parent knows about usage limits, per-customer caps and coupon lookups comes along for free, because it is the parent.
It is not free of the old sin. Overriding canProcessRule meant copying the method body and editing it, so the copied comments from upstream are still in there and any future change Bagisto makes to that method will not reach us. That is a smaller and more visible debt than a parallel evaluator in a controller, and it is in a file whose name says exactly what it is.
One field holding a list
The mechanism for stacking is the part I would flag in review, and it is one line:
$cartCouponCodes = array_map('trim', explode(',', $this->cart->coupon_code));
$coupon = $rule->cart_rule_coupon()->whereIn('code', $cartCouponCodes)->first();There is no second column and no join table. The cart's existing single coupon_code field now holds a comma-separated list, and everything that wants to know which vouchers are applied splits the string.
It is the cheapest possible implementation and it works. It also means a voucher code containing a comma silently breaks the parsing, and every other consumer of that field — the price breakdown, the Bagisto export the ticket asks for a new column in, the Google Shopping feed that reads post-voucher prices, whatever reporting exists — is now receiving a list where it has always received one value.
I have written some version of this paragraph before. In September I noticed the post-voucher price was being computed in four separate places with no single owner. This is the same shape: a field whose meaning changed without anything that reads it being told.
Fifteen days instead of ten
A smaller change with a longer history. The return policy went from ten days to fifteen — 260 additions, 296 deletions, eleven files, because the number lives in the slide-out, the FAQ and the checkout.
In the March 2024 article I quoted the FAQ answer I had written, which promised a refund within the first ten days and a one-to-one exchange within thirty. Twenty-one months later the refund window is longer, and changing it meant touching eleven files because the figure was written into copy in three places rather than read from one.
Also in December: accessory bundling, at 1,745 additions across twenty-nine files, so a case or a charger can be sold alongside a device. Two triggers removed from the exit-intent popup — four additions, fifty-six deletions — which is the third time that feature has been made to fire less since I built it in April. And a cleanup of Bagisto's order creation process that merged on 2 January.
The bot went quiet
One commit on the Discord bot in December, against a hundred and twenty-one in November.
I do not have a tidy explanation and I am not going to invent one. November was the month it grew a levelling system, moderation, a language switcher, a database migration and a security pass; December was the month I did not touch it. Side projects run at the pace of whatever attention is left over, and in December the attention went to stackable vouchers and, on the thirtieth, to an installer.
Three cookie flags nobody had set
The installer hotfix on 30 December was not December's only security change, and the other one is easy to miss because of what it is called. On 22 December I merged three additions and three deletions to one file, titled Fixing session issues in admin panel:
- 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
+ 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', true),
- 'secure' => env('SESSION_SECURE_COOKIE', null),
+ 'secure' => env('SESSION_SECURE_COOKIE', true),
- 'same_site' => null,
+ 'same_site' => 'lax',
Those are the framework's shipped defaults, unchanged since the first week of the project. The session cookie was not marked secure, so nothing in the configuration required it to travel over HTTPS, and it had no SameSite policy at all. Three lines, and the pull request title describes them as a bug in the admin panel.
This is the same lesson as the installer, arriving eight days earlier and going unremarked. I wrote at the end of December that vendoring a framework means owning every one of its defaults including the ones you never read. The installer's middleware was one. The session cookie configuration was another, in a file I had edited before for other reasons.
What makes it worth a section rather than a sentence is what happened next. On 29 December, a second one-line change to the same file:
- 'lifetime' => env('SESSION_LIFETIME', 30),
+ 'lifetime' => env('SESSION_LIFETIME', 480),
Thirty minutes to eight hours, in a pull request titled Nsys logo update & extend login sessions. The reason is real — admin staff were being logged out mid-task, which is what session issues in admin panel meant in the first place — and the flags tightened a week earlier make a longer session less alarming than it would otherwise be. But the sequence in the record is: harden the cookie in a pull request that says it is fixing a bug, then multiply its lifetime sixteenfold in a pull request that says it is updating a logo. Neither title would lead anybody back to the decision.
A logo in the footer and a policy in a filename
140 additions, 154 deletions, sixteen files, titled Nsys feature. The ticket is two screenshots and four lines of instruction: move one link, add a new one to nsysgroup.com, put a small logo at the bottom. Tiny logo.
So the footer now credits the third party that supplies the shop's device diagnostics, with a logo and an outbound link. That is worth recording plainly: the 56 tests that the product page describes, and that in January 2026 became the headline claim in place of "Great Condition", are performed with someone else's tooling, and December is when the shop said so on the page.
The rest of the pull request is housekeeping that reads as trivial and is not: images moved out of the root of the image directory into global/, pdp/ and static/ folders, and whatsapp-icon.svg deleted. Fifteen renames, and the reason the diff is 140 lines rather than 15 is that every reference had to move with them.
One more, folded into a pull request titled Fixing scroll threshold sensitivity, which is about distinguishing a horizontal swipe from a vertical scroll on the product-page image slider:
- element.innerHTML = "Before: " + currencyValue + regularPrice
+ element.innerHTML = "New: " + currencyValue + regularPrice
The struck-through reference price stopped being labelled Before and started being labelled New. It is more accurate — the figure is what the device costs new, not a price 3cat previously charged, and "Before" implied the latter.
But this is not the change; it is the second half of it. The server-rendered copy of that label was corrected in September, in a pull request titled PDP Savings Clean Up. This one fixes the JavaScript copy, which rewrites the same price when a customer picks a different variant. For three months the product page said New: when it loaded and Before: the moment you changed the storage size.
So the honest version is worse than the one I would have told. A price claim I had already decided was misleading was fixed in one of the two places it lives, and the other place went on making the misleading claim for a quarter of a year — then got fixed inside a pull request about touch gestures, in a month whose headline items were vouchers and an installer. The duplication habit I keep naming in this series is not only a source of bugs. It is a source of copy that says two different things depending on what the customer just clicked.
What December was
Fifteen pull requests, all merged. Vouchers that stack, accessories that bundle, a return window five days longer, and an installer that can no longer be reached.
The two ends of the month describe the job better than either does alone. The voucher work is me correcting a decision I made fourteen months ago, deliberately, with the benefit of having watched it fail four separate times — and the correction is to stop writing my own version of something the framework already does. The installer is the reverse: a default I inherited on day one, never read, and found in the last week of the year. One is the cost of writing too much. The other is the cost of reading too little.
Revised: this article originally covered the installer hotfix and the voucher work and left out December's other security change — three session-cookie defaults, then a sixteenfold increase in session lifetime — along with the Nsys footer credit and the second half of a price-label fix whose first half shipped in September. The sections above were added from the original diffs.