Twenty-three pull requests in December, twenty-one merged. A year earlier I had opened nine in my first month on a codebase that was two weeks old and could not display a product.
This December had no architecture in it. It was a month of a shop being run: a marketing bug that had been quietly costing money, a voucher rule that evaluated the wrong way round, a Christmas hat, and the bill arriving for a shortcut I took in October.
Put a hat on the cat
On 17 December I merged a pull request titled 1078: Put a hat on the cat. The ticket read, in full: Let's put a christmas hat on our cat for Dec!
Two files, zero lines:
modified 3cat/resources/images/header/logo.webp
added 3cat/resources/images/header/logo_original.webpNo date logic, no feature flag, no seasonal-asset system. The hat version overwrote the logo in place, and the real logo was saved beside it under a new name. On 31 December I merged the reverse — 1112: Remove Hat from Cat — which restored logo.webp and deleted logo_original.webp.
I have thought about whether that was the right call and I am fairly sure it was. A configurable seasonal logo system would be perhaps two hundred lines, a config entry and a date comparison that somebody has to reason about every December. This was a file swap with the rollback plan sitting next to it in the same directory, named so obviously that anybody could undo it without asking me. The hat was up for exactly fourteen days.
It is also the only ticket in thirteen months that made me laugh when it arrived.
The shortcut came due
In October I wrote about hand-parsing Bagisto's cart rule conditions, because their engine could tell me a voucher did not apply but not which condition rejected it. I said at the time that a partial reimplementation of somebody else's evaluator would drift. It did not drift. It turned out to be incomplete from the start, and December is when somebody noticed.
On 19 December I raised and merged a hotfix in one day: voucher exclusions were not being honoured on the frontend. The condition evaluator looked like this:
case 'product|name':
case 'product|sku':
$productValue = $context['product']->{str_replace('product|', '', $condition['attribute'])};
if (!str_contains($productValue, $condition['value'])) {
$isValid = false;
}Read what that does with the operator: nothing. It never looks at it. Every product-name and SKU condition was evaluated as contains, whatever the rule actually said. So a voucher configured to exclude a product — name does not contain iPhone 15 — was evaluated as name contains iPhone 15, which is precisely backwards. Marketing set up an exclusion, the admin panel showed it correctly, and the checkout applied the discount to exactly the products it was meant to withhold it from.
switch ($condition['operator']) {
case '{}':
if (!str_contains($productValue, $condition['value'])) { $isValid = false; }
break;
case '!{}':
if (str_contains($productValue, $condition['value'])) { $isValid = false; }
break;
case '==':
if ($productValue !== $condition['value']) { $isValid = false; }
break;
}This is the cost of the October decision, and it is worth being precise about what the mistake actually was. Reimplementing the evaluator was defensible — the product requirement genuinely needed a reason, not a boolean. Implementing one operator out of four and shipping it as though it handled all of them was not. I built something that looked like a rule engine and behaved like a substring search, and the admin panel kept offering operators it silently ignored.
A partial reimplementation is only safe if it refuses the cases it does not handle. Mine agreed to all of them.
Twenty percent of the traffic, none of the orders
The best bug of the month was found by somebody reading a dashboard:
Sponsored Search accounts for ~20% traffic, Organic Search ~40%
However, there's been no orders logged for Sponsored Search traffic on the
Order Attribution field in Bagisto since feature was released last week.Not a few orders. None. A fifth of the traffic was arriving from paid advertising and not one order had ever been attributed to it, which means the reports said the ad spend was producing nothing. Somebody then did the right thing and reproduced it: a test purchase through a paid link in incognito mode came out attributed to Direct.
The attribution script decided a visit was paid by looking for UTM parameters:
- if (utmSource || utmMedium) {
+ if (utmSource || utmMedium || gadSource) {Google Ads does not always send UTM parameters. It sends gad_source. Every paid click that arrived without a UTM tag fell straight past the paid branch, hit the organic and direct checks below it, and was filed as whatever it looked like. The whole fix is nineteen additions.
What I take from it is that this bug could only ever be found from the outside. There is no error, no log line, no failing test — the code did exactly what it was written to do. It took somebody comparing two numbers that should have moved together and noticing that one of them was zero. A follow-up pull request four days later stopped the parameter being usable directly, and the last commit of my year was on that branch.
I will note one thing in that diff I would tidy. After the paid branch returns, the direct check reads ((!utmSource || !gadSource) && !referrer) — but at that point every one of those parameters is already known to be absent, so the condition cannot be false either way. It is not a bug. It is a condition written as though it were still deciding something.
A surprise in a box
The month's largest feature came from a genuinely good piece of observation about customer behaviour:
- Users who intend to make a 3cat.my reservation to collect their device
in store aren't able to apply store vouchers on their order
- As a result, users are likely to be frustrated when they show up at the
store and realise there are additional vouchers they could've applied
- To avoid paying more, users would cancel orders and place them again in
store - creating unnecessary additional work for our team.An online reservation that a customer cancels and re-places at the counter costs the company twice and annoys everybody. The fix was to tell the customer, at the moment they reserve, that a store voucher is waiting for them — a slide-up panel with, and I did not name this file, a surprise-box.gif.
It is the same behavioural design as November's delivery vouchers, which were deliberately not auto-applied so customers would feel they had been given something. Ninety-three lines of new modal, seventy-five of stylesheet, a hundred and ten in the cart controller. By this point the checkout was less a form than a series of small negotiations.
Assuming the lookup found something
On 3 December I fixed a bug I had filed myself: creating a new product variant in the admin panel cleared the product page.
- return $method($variantProduct, $value) ? $this->getPath($variantProduct) : null;
+ if ($variantProduct && $method($variantProduct, $value)) {
+ return $this->getPath($variantProduct);
+ }
+ return null;The cache invalidation walked the submitted form data, looked up each variant in the product's loaded relation, and asked whether its price or stock had changed. A variant being created is not in that relation yet, so the lookup returned null and the method call on it threw — taking the whole invalidation down and leaving the page cached wrong.
This is the third time in this series I have written up the same mistake. March: indexing categories[0] on a collection that could be empty, which made trade-in products invisible. July: changing that pattern to first() in a different file while hunting queries. December: assuming find() found something. Same shape, three files, ten months apart. I clearly know the lesson and I clearly do not yet write code that has it built in.
Two lines that were not on the ticket
Inside the store voucher pull request, unrelated to store vouchers, is a change to the production PHP configuration:
- error_reporting = E_ALL
+ error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICTProduction had been reporting every notice and deprecation since the day the container was built. The new value is the one the file's own comment recommends for production. Good change, and it had no business being in a pull request about vouchers — the same habit I flagged in August, where two unrelated concerns share a branch and the reviewer approves the one they were asked about.
The other line I noticed while reading that file is one I put there in February:
const WARRANTY_PERCENTAGE = 0.1;In February I valued the one-year warranty at ten percent of the product price because no real figure existed, wrote that I was not proud of it, and said hardcoded numbers leave a codebase when somebody gives the template a real source to read from. Ten months later it has been promoted to a named constant on a class, which is tidier and is not the same thing as fixed. Nobody ever gave it a source. It is still ten percent.
The favicon, three times
The smallest thread of the month, and my favourite kind of detail. In October I merged Improvement of Favicon. On 2 December another favicon pull request deleted favicon.ico outright. On 19 December a third one put it back, along with an Apple touch icon and a 196-pixel version, and removed the file the December 2 change had introduced.
Three attempts across three months at the smallest image on the site. Browsers each want a different size in a different format from a different path, and no amount of care gets it right without a device in your hand to check. There is no lesson in it. It is just what maintaining a real website is like, and I would rather record it than pretend the year was all architecture.
The third place a price gets worked out
Fifty-two additions, eight deletions, five files, and I skipped it because the title reads like housekeeping: Calc instalments with vouchers + correctly padding. It is the month's most consequential small change and I did not see it.
The problem is real. A voucher lowers the order total, and the checkout offers to split that total into monthly instalments. If the monthly figure is rendered once by the server and the total then changes in the browser, the customer is looking at instalments for a price they are no longer paying. So this adds a recalculation:
recalculateInstalmentsPrices() {
const config = this.instalmentsConfigTarget;
const instalmentsMonths = parseInt(config.dataset.instalmentsMonths);
const cardInstalmentsMonths = parseInt(config.dataset.cardInstalmentsMonths);
const newInstalmentsPrice = this.grandTotalValue / instalmentsMonths;
const newCardInstalmentsPrice = this.grandTotalValue / cardInstalmentsMonths;
if (this.hasInstalmentsPriceTarget) {
const instalmentsText = this.translationsValue['instalments_subtext']
.replace(':price', this.formatPrice(newInstalmentsPrice))
.replace(':month', instalmentsMonths);
this.instalmentsPriceTarget.innerHTML = instalmentsText;
}
...
}
Correct behaviour, and look at where the arithmetic now lives. The monthly instalment a customer is shown is computed in the browser, by dividing a JavaScript value by a number read out of a data- attribute, then substituted into a translated sentence with two .replace() calls. The server computes the same figure for the initial render. Neither knows about the other.
In September 2025 I wrote that the post-voucher price was being computed in four separate places with no single owner, and offered it as a thing I had noticed rather than a thing I had caused. This is one of the four, and I added it — nine months earlier, in a pull request whose title is half about padding, in the same month I was writing about a voucher engine that ignored its own operators.
That is the more honest framing of the December voucher story. The article as I first wrote it says the condition evaluator I built in October was wrong and I fixed it. What it leaves out is that the same month I was extending the same feature outward — teaching a second consumer of the total to work the price out for itself, in a different language, on the other side of the network.
Two related odds and ends, both in the same instalment templates: a data type corrected in the payment-methods partial, and the trade-in section repadded on mobile. And a fix to the order in which homepage images are prefetched, eighteen additions and thirteen deletions, which is the performance thread this year keeps returning to and which I gave no space at all.
A year in
Twenty-three pull requests. A voucher rule that finally reads its own operators, an advertising channel that started being credited for the orders it produced, a reservation flow that tells customers about money they did not know they had, and a cat that wore a hat for fourteen days.
Thirteen months, around nine hundred contributions, all of it in one private repository. In December 2023 I joined a two-week-old Laravel install with no shop in it, serving four stores in Kuala Lumpur, and my first real task was making a product page stop advertising discounts that did not exist. In December 2024 the same application was selling across eight states, taking real payments through a gateway, printing invoices, running campaigns marketing configured themselves, and attributing orders to the channel that produced them.
The through-line is not any one feature. It is that almost every serious bug this year came from an assumption nobody had written down — a postcode that was secretly octal, a promotion whose precedence rules lived only in my head, a rule engine that ignored its operators, a paid click that forgot to bring a UTM tag. The code was rarely wrong about what it was asked. It was wrong about what it assumed, and the only reliable way I found to catch that was to look at production with the specific suspicion that something quiet was broken.
Revised: this article omitted the pull request that added a second, client-side computation of the post-voucher instalment price — one of the four competing price calculations I would complain about, without attribution, nine months later. The section above was added from the original diff.