Dansday

The Month I Turned the Cache Off Quietly

Published on Jan 31, 2026

Fifty-five contributions at 3cat in January, and nothing anywhere else. Twenty-five pull requests merged, every one of them. Twenty-four opened, and all twenty-four merged too, though one of them not until February.

The number worth looking at twice is the volume. Those twenty-five merged pull requests came to 6,872 additions and 5,213 deletions across eighty-nine files — but 6,033 of the additions belong to a single pull request I opened in December. Take that one out and January reads +839 / −1,469 across seventy-three files. Two dozen pull requests, and together they deleted nearly twice what they wrote.

On the Discord bot: nothing. Not one commit. A hundred and twenty-one in November, one in December, zero in January. In December I wrote that side projects run on whatever attention is left over and that I would not invent a tidier explanation. That still stands, and now it has a second month behind it.

Twenty-two minutes

Stackable vouchers merged on 20 January at 06:07 UTC. At 06:29 I filed the first production fix.

Nine lines, one file, a null check at the top of the endpoint that removes a voucher from the cart:

// Check if cart exists
$cart = Cart::getCart();
if (!$cart) {
    return new JsonResource([
        'success' => false,
        'message' => trans('No cart found.'),
    ]);
}

Someone clicked the little x to remove a voucher on a page whose cart had already gone, and the request went straight into a method call on null. Twenty-two minutes from merge to the fix being open.

The next morning brought the second one: 103 additions, 89 deletions, and almost all of it indentation. I wrapped the whole coupon-application path in a try block that had previously started halfway down the method, and added a catch for validation failures so that a bad request comes back as a message instead of a five-hundred. The diff looks large and says something small: I had written the happy path inside a guard and left the setup — validation, cart creation, shipping method — outside it.

This is the fifth time in this series that voucher code has needed a same-week fix after shipping. October 2024, December 2024, April 2025, July 2025, now January 2026. I said in December that walking into this deliberately was the right call and not a comfortable one. Twenty-two minutes is roughly the discomfort I had in mind.

A correction to last month's article

December's piece called stackable vouchers "the month's largest feature" and described the 274-line CartRule helper in the past tense. The code was written in December. The pull request was not merged in December. It sat open for thirty-two days and merged on 20 January.

I did not say so at the time, and I should have. The distinction matters in exactly the way January demonstrated: written is not the same as merged, and merged is not the same as survived. Everything I said about the design still holds — extending the framework's own helper instead of reimplementing it beside it was the right correction to a fourteen-month-old mistake. What I got wrong was the tense.

Six pull requests for one hostname

The warranty portal moved from 3cat.my/warranty to hub.3cat.my, and got renamed to CatHub on the way. That is a small ticket. It took six pull requests across ten days and one revert.

On 6 January I did it the cheap way: an nginx sub_filter rewriting the old path in outgoing HTML, plus a 301 from /warranty to the new host. Two pull requests for production, one for staging. On 12 January I reverted all of it — zero additions, eleven deletions — because rewriting links in the response body and redirecting the path is not the same thing as serving the portal on its own hostname, and the difference shows up the moment anything deep-links.

On 15 January I did it properly: real server blocks for hub.3cat.my and hub.3cat.work, 113 additions, and one line that explains why the first attempt was never going to work — the production vhost had been claiming every subdomain:

-    server_name ~^(?:.+\.)?3cat\.my$;
+    server_name 3cat.my;

Then on 16 January, a fourth follow-up: the Docker build was deleting the new config files. The image build keeps one nginx config per environment with a find ... ! -name allowlist, so a brand-new vhost is silently removed at build time unless you remember to add it to the list. I did not remember. The vhosts worked locally and vanished in the built image.

One other thing happened in that first pull request that has nothing to do with nginx. Moving the portal's Supabase project to a paid account meant repointing four environment files, and all four now point at the same project. Before, dev and QA shared one and staging and production shared another. Now there is one, and staging writes where production reads. That is the same shape as pull request #902 — the production database dump that has been the canonical development database since October 2024 — and I noticed it while reading my own diff for this article, not while writing it.

The response cache left without a note

On 12 January I opened a pull request titled "Optimize DB query in homepage product lists", and it merged three days later. Five files, seven additions, ten deletions. Three of the changes are what the title says: eager-loading a relationship, caching a repeated attribute read in a Blade loop, dropping an unused with(). The fourth is this:

     Route::get('/', HomepageController::class . '@index')
-        ->name(RouteConfig::SHOP_INDEX_ROUTE_NAME)
-        ->middleware('cacheResponse');
+        ->name(RouteConfig::SHOP_INDEX_ROUTE_NAME);

     Route::fallback(SlugHandlerController::class . '@index')
-        ->name(RouteConfig::PRODUCT_OR_CATEGORY_INDEX)
-        ->middleware('cacheResponse');

That is the homepage and every product and category page. Those were the only two routes still using it. As of that pull request the application-level response cache is not applied anywhere in the codebase.

Read the thread back. November 2024: I built cache invalidation, and wrote about 863 lines lost to 137. May 2025: it was firing too often, so it got rate-limited and debounced. September 2025: replaced in practice by a ten-minute nginx TTL, with a 1,151-line deletion pull request opened to remove the CloudFront invalidation that fed it — a pull request still open at the end of January, sixteen months after the feature it removes was written. And then in January the last two routes lost the middleware inside a five-file performance tidy-up, with no mention of it in the title, the body or the ticket.

Three articles of engineering ended in a diff hunk nobody would look at twice. The Composer requirement for the package is still there, and the framework upgrade I opened ten days later even bumped its version.

840 lines of a payment gateway I shipped

Issue #1675, "Cleanup iPay88 & senangpay", was one of the open threads I listed going into 2026. It closed on 15 January: 32 additions, 840 deletions, twenty-three files, eleven of them deleted outright. Two callback handlers, two payment drivers, two config files, a request builder, a language file, a form view.

I integrated iPay88 in January 2024. That was the month the shop started taking real money, and I wrote about it as the point where the work stopped being cosmetic. Two years later the whole integration comes out in one afternoon because the shop settled on a different gateway and nobody had gone back to remove the loser.

The interesting line in that diff is not a deletion. The eGHL callback handler — the gateway that stayed — came out twenty additions and forty-nine deletions lighter, because a chunk of it existed to disambiguate between three providers that no longer needed disambiguating. Deleting dead code made the live code shorter. That does not always happen.

"Have" and "no", written three times, fixed once

On 12 January I added four filters to the admin order grid: voucher code, tracking link, POS invoice ID and ship-out date, each a Have/No dropdown, plus an includes/excludes filter on customer name. 135 additions, one file.

On 21 January I filed my own bug against it and fixed it the same hour:

-    $scopeQueryBuilder->orWhereNotNull('shipments.track_number');
+    // Has tracking: not null AND not empty string
+    $scopeQueryBuilder->orWhere(function($q) {
+        $q->whereNotNull('shipments.track_number')
+          ->where('shipments.track_number', '!=', '');
+    });

An empty tracking number is not a null tracking number. Orders with a shipment row and a blank field were counted as having tracking, so the warehouse's "no tracking" filter hid exactly the orders it existed to find.

Here is the part I would fail in review. I wrote that pattern four times in the same file on 12 January — for tracking number, POS invoice ID, ship-out date and voucher code — and on 21 January I fixed one of them. The other three still read orWhereNotNull, and reading the file again while auditing this article seven months later, they still do:

$scopeQueryBuilder->orWhereNotNull('invoices.pos_id');
$scopeQueryBuilder->orWhereNotNull('shipments.shipped_at');
$scopeQueryBuilder->orWhereNotNull('orders.coupon_code');

The POS field is written by an integration. The ship-out date is written by whatever marks a shipment as gone. And the voucher field, as of the pull request that merged the day before, now holds a comma-separated list rather than a single code. None of the three is more likely than a tracking number to be reliably null rather than blank.

This is the "missing data assumed present" habit I named in earlier months — categories[0] in March 2024, first() in July 2024, find() in December 2024, json_decode()[key] in February 2025 — wearing a new coat. Same assumption, different flavour: that absent means null, and null is the only way to be absent.

Great Condition became 56-step checks

Twenty additions, twenty deletions, four files, and the longest-running thread in this series. Every place the shop said "Great Condition" now says "56-step checks". The homepage strip, the footer, the product page, the popup, the FAQ entry.

The old FAQ text read: "Our used devices are in Great Condition with battery health guaranteed to be at least 85%". The new one leads with the checks and the number. It is a real improvement of the kind this thread has been waiting for — "Great Condition" is a claim you cannot check, and "passed 56 careful tests" is one you can ask about. The honesty-of-copy thread has run from hardcoded discounts in December 2023, through "Low Stock" at zero quantity in November 2024 and "Rare Gem" in March 2025, to pre-order SLAs in June 2025. This is the first entry in it where the fix was to make a claim more specific rather than to stop making it.

I will note that "exactly as good as new" survived the edit, sitting immediately after the sentence about the fifty-six tests. A used phone is not exactly as good as new. That is the bit still doing the old job.

The popup lost the rest of its triggers

Eighty additions, 344 deletions, one file. The exit-intent popup now has one trigger: ninety seconds of cumulative inactivity, reset by any click, scroll, cursor movement or focus change. Everything else went — the desktop mouse-leave detection, the mobile listeners, the per-page-type time thresholds of seventy-five seconds on product pages and forty-five at checkout, the six-second initial delay, the three separate session-storage flags tracking whether it had already fired.

The ticket was marked P0 and said it plainly: "All other triggers for Exit Intent popup must be removed."

That file is now on its seventh pull request. It shipped in April 2025 at 1,204 additions. It gained a cumulative timer three weeks later, a six-second inactivity gate in June, an emergency disable on two page types in August, a behaviour fix the same month, two triggers removed in December, and in January everything but one timer. I built a feature with six ways to fire and have spent nine months taking them away one at a time.

What January was

Also in the month, briefly. Free reservations stopped reporting themselves to Meta as purchases — a good ticket, well argued, with the reasoning written down: free reservations cancel often, the pixel was teaching the algorithm to find people who reserve and do not buy, and paid orders convert at eighty per cent. The Bagisto product export was returning corrupt files, and the fix was to quietly upgrade any XLS request to XLSX because the older writer mishandles large files. A cleanup of the order creation process merged on 2 January, adding a service that cancels a customer's most recent failed order before letting them start a new one, on a three-day window keyed by phone number. Accessory bundles were re-sorted most-expensive-first with the recommended option moved accordingly. A wildcard certificate was renewed.

And on 22 January I opened the framework upgrade: 1,458 files, 101,438 additions, 63,208 deletions, Laravel 10 to 11, and 1,099 of those files inside the vendored packages/ tree I have been describing as a cost since July 2024. The ticket, from a colleague, is one sentence long and entirely correct: "Current version is 1 year outdated. We should upgrade it regularly, ideally every 3 months." It did not merge in January. I will write about it when it does, in the tense it deserves.

Issue #724 — the site periodically unresponsive — is still open. The hardcoded warranty percentage is still hardcoded. The post-voucher price is still computed in four places.

What January was, then: a month of taking things out. A dead payment gateway, five of a popup's six triggers, a response cache that had cost three articles to build and maintain, a redirect I had shipped four days earlier. The two dozen pull requests I opened deleted twice what they added, and the one thing I did add at scale — two vouchers where there had been one — needed its first patch twenty-two minutes after it went live. I am not sure those are separate observations.