Dansday

3cat.my

3cat.my

3cat sells refurbished Apple devices in Malaysia, and since September 2026 in the Philippines as well. The shop is Laravel, built on Bagisto, and I have been working on it since 20 December 2023. This is the longest continuous engagement of my career and the codebase where I have made the most mistakes in public, so it is the page where being accurate matters most.

Repository        3cat-Sdn-Bhd/3cat (private), created 5 December 2023
My first commit   20 December 2023
My most recent    13 September 2026
Commits on main   2,919 total, 1,000 of them mine
Contributors      16 distinct authors; I am the largest at 34%
Pull requests     594 of mine merged, 62 closed without merging
My commits by year    2023   28
                      2024  359
                      2025  173
                      2026  440   (to 13 September)

What this page used to claim, and what it got wrong

An earlier version listed six achievements with numbers attached: a threefold lift in Q4 2024 sales, invalid submissions cut by 97 percent, incident response time cut by 99 percent, database load down 95 percent, a 40 percent lift in key performance indicators.

I cannot verify any of those from the repository, because business outcomes are not in git. Some of them may well be true — I remember the reservation confirmation step genuinely stopping a flood of accidental orders. But "99 percent" and "95 percent" are stated with a precision that no source I can reach supports, and a page that presents unverifiable figures next to verifiable work makes the verifiable work look like marketing too. So they are gone.

The version that replaced them was more careful and still has a number I can no longer reproduce. It said the vendored framework was 15,934 files. Counting what git actually tracks, the framework directory held 2,457 files in late August and holds 3,585 today. I do not know what the larger figure counted — most likely a walk of the working directory, which picks up build output and installed packages that are not in the repository at all. The correction matters more than the number: I wrote a page specifically about not trusting remembered figures, and put a figure on it that I had not taken from git.

One claim from the oldest version survives, and it is the one that admitted a cost: that I shipped a cart discount field in a day by cutting the QA cycle, accepting a higher bug risk for the revenue. That is true, it is the first entry in a pattern this page is largely about, and I will come back to it.

I did not start this codebase

The first commit is not mine. The repository was created on 5 December 2023 by another engineer, with a continuous integration config added the same day. I arrived fifteen days later, and my first commit is titled IP-28437: PDP Core.

That prefix matters. In 2023 and through much of 2024 my tickets here carried an IP- number, because I was working on this shop through iPrice. By 2025 the prefix is gone and every ticket is a bare number in 3cat's own sequence. Nobody wrote a commit explaining that transition; you can only see it in the shape of the subject lines. The relationship changed and the ticket numbering changed with it.

So this is a shared codebase with sixteen contributors, and I am the largest of them without having designed it. Almost everything below is a decision made inside constraints somebody else set, which is the normal condition of commercial software and not something I want to write around.

A note on my own numbers: I nearly published a lower count for this page, because I have committed under two names and three email addresses over three years and my first query only caught one of them. 1,000 is the figure after finding the rest. If I can undercount my own work by a quarter, I can be wrong about anything else here too, which is why every number on this page came from a command rather than from memory — a rule I have now broken once, as the section above records.

The framework lives inside the repository

Bagisto is not a dependency here. It is vendored into the tree at 3cat/packages/Webkul — 3,585 tracked files, 2,723 of them PHP.

The consequence shows up every time upstream moves. An upgrade is not a version bump, it is a hand-resolved merge against a framework somebody else keeps changing. In February 2026 an upgrade broke an administrative screen, and the fix was to copy one more upstream file into our tree — which resolves the immediate problem and enlarges the thing that caused it.

On 4 September 2026 a colleague did the thing I had spent two years describing as unsolvable: replaced the entire vendored core with Bagisto 2.5.0-beta1 in a single commit. 2,797 files, 343,347 insertions against 199,850 deletions, with a findings document committed alongside it recording what the copy had broken, and a follow-up the same day titled Fix real bugs behind failing 2.5 upgrade tests. It is on the default branch.

I want to be precise about my part in that, which is none. I wrote on this page that I had not solved this, that I was not sure it was solvable from where the project stood, and that pretending otherwise would be worse than admitting it. Somebody else read the same codebase and did it in a day. The honest reading is not that I was wrong about the cost — the diff is enormous and the tests did fail — but that "I am not sure it is solvable" is a sentence I should have treated as a question to put to a colleague rather than a conclusion to publish.

The thread that runs through everything: is the number on the screen true

If this engagement has one subject, it is this. Nearly three years of it, in order:

December 2023. A product page showing a discount that had been hardcoded, not calculated. My first month.

November 2024. A "Low Stock" badge rendering on products whose quantity was zero. Not low. None.

March 2025. A "Rare Gem!" label, applied by a rule that had nothing to do with rarity.

June 2025. Pre-order delivery commitments displayed as service-level promises the operation had not agreed to.

January 2026. "Great Condition" replaced with "56-step checks" — a claim about process rather than a claim about the item, and a real improvement, because a process claim can be audited and an adjective cannot.

February 2026. Then this. A display_quantity attribute was added, so "Only 3 left!" became a field somebody types rather than a count of anything. It is still there today, across three migrations and five source files, alongside a companion attribute for marking an item a hot take. The urgency on the page is still editorial.

July 2026. The product page switched to showing the price after an automatically applied delivery voucher — a lower, more attractive figure, and one the customer genuinely pays when the voucher applies to them. The same change had a second half that computed "from RM X" on listing pages using only variants you can actually buy, rather than any variant including the sold-out cheap one. That half was reverted and has not come back.

September 2026. A new entry, and for once it moves the right way. The shop opened in a second country, and a test went in that walks every view and every experiment file and fails the build if any of them hardcodes a country's own domain. It is twenty-three lines and a regular expression. It exists because three separate surfaces — an exit-intent popup, a trade-in string, and a controller — were telling Philippine customers they were on the Malaysian site. Instead of fixing the three and moving on, somebody made the fourth one impossible to merge.

I used to think this thread was a story about progress. It is not. It oscillates. The same month can make one number more honest and another number less honest, and the pressure that produces the dishonest ones — a promotion calendar, a conversion target, a competitor's page — never goes away. What September adds is the one countermeasure that has ever worked here: not noticing the problem, but encoding the noticing in CI so the next person does not have to notice it at all.

Two countries out of one codebase

September was the Philippines, and it was most of my sixty-five commits that month. Cash on delivery, GCash, a new payment gateway, a fifteen-day return policy, a per-country notification icon, a homepage that stops referring to the wrong country, a config panel so the country-specific switches stop being code, and traffic-source configuration for partner app webviews.

Instalments landed alongside it: a service provider, a split-payment controller, a finance-partner settings screen, and a per-product cap on the maximum tenure — which is the sort of flag that exists because a device worth two hundred ringgit financed over twenty-four months is a support ticket, not a sale.

The payment method directory now has six entries where it had one. That is the clearest measure of what this year has been: the shop's identity moved from "a Malaysian store" to "a store that is configured per country", and almost none of that work is visible on a product page.

The infrastructure half was a colleague's: a container deployment tool, a separate image for the Philippines rather than one extending Malaysia's, object storage served through the application's own path, and cache windows tuned per country. My part was the two days of 500 errors that followed, which is the ordinary shape of adopting somebody else's deployment tooling while a store is live.

Vouchers: nine bugs, then a tool

Conditional discounts have produced a bug in nine separate months of this project. In order: a hand-rolled condition evaluator in October 2024; an operator comparing the wrong way round in December 2024; a revert in April 2025; a cascade in the XOX telco mechanism in July 2025; a helper introduced in December 2025 to stop the duplication; stackable vouchers patched twenty-two minutes after the merge that broke them in January 2026; an exclamation mark missing from a client-side allow-list in February 2026, next to a framework validator that returned an undefined variable for the same input; a first() call in June 2026 that only became wrong once the feature was used as designed; and in July 2026 a delivery voucher outliving the delivery it discounted after the customer switched to reservation.

The through-line is that discounts here are conditional on cart state, and for most of those nine months nothing owned the job of re-deriving them when the state changed. Every fix was a listener or a guard for one transition.

What exists now is more interesting than any of the fixes: a controller whose entire job is to preview which orders a cart rule's conditions would match, before you save the rule. After nine months of discovering condition logic in production, the codebase grew a way to look at it first. That is the correct ending, and it cost nine bugs to arrive at.

The cache I built, and its complete absence today

In November 2024 I built application-level response caching. In May 2025 it had to be rate-limited. In September 2025 it was largely replaced by a ten-minute cache at the edge, alongside an open pull request deleting 1,151 lines. In January 2026 the last two routes quietly lost their cache middleware inside an unrelated tidy-up, which I only noticed while writing about that month. Today there is not a single reference to that middleware anywhere in the codebase.

An edge cache with a short time-to-live replaced a year of my invalidation logic, and it works better, because invalidation is the hard part and a TTL sidesteps it entirely. In September 2026 a colleague extended the catalogue's stale window from 900 seconds to twenty-four hours, first for the Philippines and then for Malaysia. A full day of deliberately stale catalogue pages, served knowingly, revalidating in the background. That is the opposite end of the axis from where I started, and the shop is faster for it.

The honest summary is that the most sophisticated system I built for this shop was retired in favour of something simpler that someone else could operate, and then tuned further in a direction I would have argued against. That is usually what should happen.

The bug I keep writing, and still have not fixed

Five times in this codebase I have assumed data was present when it was not: an array's first category in March 2024, a first() call in July 2024, a find() in December 2024, a JSON key in February 2025, and a set of orWhereNotNull filters in January 2026.

Those last ones are the point of this section. In January 2026 I found three of them in the order administration grid, wrote them up as unfixed, and published that. In August I opened the file again while revising this page, found them unchanged, and published that too.

I opened it again today. Three orWhereNotNull calls, on a point-of-sale identifier, a shipment timestamp and a coupon code. Each turns a filter that should narrow results into one that widens them, so a staff member filtering orders gets rows that do not match what they asked for, silently, with no error. Same three lines, same file.

Eight months of knowing, two public write-ups, and no fix. I have no explanation that reflects well on me. It is a small bug in a screen only staff see, it never shouts, and there has always been something with a ticket number in front of it — this quarter, an entire second country. That is exactly how this class of defect survives, and I now have documentary proof that writing it down twice does not make me act on it. If there is a lesson available, it is that a public confession is a substitute for a fix rather than a prompt towards one.

Money, and the gap between two state machines

The most consequential work here is always about payment. August 2026 was almost entirely the reservation and deposit flow, ticket 2139 and its follow-ups, across three weeks. September was a second country's payment methods.

The one to read is a hotfix from 12 August, titled Paid Reservation orders bypass payment gateway and are marked Ready to Process. Orders were reaching a state that means "the money is in, begin fulfilment" without going through the gateway. The fix is three files and thirty-three lines: a guard in the order controller, a change to the order listener, and two lines in the cart's front-end controller.

That is the shape of nearly every serious bug I have fixed here. An order has a state machine. Money has a state machine. They are separate, they are updated from different places, and the bugs live in the space between them. July 2026's version was a transaction row written the moment a customer was redirected to pay, describing a payment that had not happened and, if they abandoned the page, never would. The fix there was deleting the method that created it, and making the callback write a row only for a paid status.

Both fixes are subtractions. That keeps being true: the good changes in this codebase are usually removals — 810 lines of an unused sales reporting module, 345 lines of an API controller, a hardcoded currency pulled out of twenty-four files, a method that recorded intentions as facts.

What actually got better, in one commit

On 19 August 2026 I deleted two workflow files that ran an AI reviewer on every pull request, ninety-four lines of them, and in the same commit added a forty-five line workflow that runs the tests. That same week has commits titled Fix 1 unit test and Fixing test unit, and so does every week since — September has four more of them.

I had put the AI reviewer in place in April. Four months later I replaced a machine that comments with a machine that fails the build, which is the trade I would recommend to anybody. An automated reviewer on a solo-merged repository produces reading material. A test suite in the merge path produces a decision. The country-leak test is what that decision looks like once other people start using it.

Two things I will not describe here

In December 2025 I found and reported a vulnerability in the framework's installer. I have written about it at the level of what class of problem it was and nothing more — no routes, no conditions, no mechanism — because the framework is used by other shops and some of them will not have updated.

And in July 2026 two infrastructure pull requests added material to this repository that should never be in one. I am not going to be more specific than that. The repository is private, but a private repository is an access boundary, not a secret store, and it has been done this way since the project's first commit in December 2023, so it is a standing practice rather than a July mistake. The remedy is unglamorous: rotate, move the material into something the deployment reads at boot, and treat the history as compromised rather than assume nobody looked.

The small things that never end

The browser back button has been broken and fixed in five separate months. The chat call-to-action button has been changed in more months than any other single element on the site. The exit-intent popup has taken eight pull requests, with triggers removed one at a time and then added back; it is still in the codebase today, its own component, its own script, its own stylesheet — and in September it needed two more commits, one to stop it leaking the wrong country's domain and one because the close button was too far from the corner to hit.

These are not failures of attention. They are what happens when a marketing surface has several owners and no written contract about what it should do. The engineering fix is not in the code, and I have never managed to get the non-code fix agreed.

What nearly three years on one codebase taught me

That the interesting problems are not technical. The orWhereNotNull bug is a five-minute fix that has survived eight months of me knowing about it and writing it down twice. "Only 3 left!" became a text field because somebody needed urgency and the honest number would not provide it. Nine voucher bugs happened because conditional pricing had no owner, not because conditions are hard.

That a vendored framework is a debt you service forever — and that "unsolvable" was my assessment, not a property of the codebase, which a colleague demonstrated in a day.

That the only defence against a claim drifting away from the truth is a test that fails when it does. Every other mechanism I have tried here, including this page, is a record of drift rather than a brake on it.

And that being able to describe a problem accurately is not the same as fixing it. This page is more precise about the state of that order grid than any ticket in the tracker, it has been for eight months, and the code has not changed. If I take one thing from writing it, that is the thing, and I should probably stop taking it and fix the grid.

Stack

Framework    Laravel with Bagisto, vendored into the repository (2.5 since September 2026)
Language     PHP
Front end    Blade, Tailwind CSS, Alpine-style JS controllers
Data         MySQL; Postgres and Supabase storage on the Philippine store
Payments     six methods including eGHL, PayMongo, GCash, cash on delivery,
             pay at store and instalments; reservation and deposit flows
Caching      edge cache with a stale-while-revalidate window per country
Integrations telco partner mechanism, webhooks with attribution passthrough,
             an authenticated machine-readable stock surface with its own keys,
             a workflow automation consumer, a warranty portal on Supabase
Deploy       container deployment per country, separate images
CI           tests in the merge path since August 2026