One hundred and nineteen contributions at 3cat in February and forty-three pull requests merged, every one of them. My busiest month in twenty-seven months of this by a wide margin — the previous record was twenty-five, set in January, the month before.
The raw totals are meaningless on their own: +107,204 and −65,538 across 1,723 files. Almost all of that is the framework upgrade I opened on 22 January, which finally merged on 5 February. Take that one pull request out — its four follow-up repairs still counted in — and February reads +5,766 / −2,330 across 265 files, which is still the most hand-written code I have shipped in a month.
Away from work, on 21 February I created a public repository and on the 22nd I pushed eighteen commits into it. It is this website. More on that at the end. The Discord bot got nothing, for the third month running.
The upgrade landed, then asked for four more things
1,458 files, Laravel 10 to 11, doctrine/dbal 3 to 4, Elasticsearch 8 to 9, and 1,099 of those files inside the vendored packages/Webkul/ tree. Fourteen days from opening to merge. In January I said I would write about it when it merged, in the tense it deserved, so: it merged, and it broke four things.
Redis configuration needed correcting across every environment file. A production bug fix the next day. A revert of the lazy-loaded livestream header, which I will come back to because of what else was in it. And this, which is the one worth looking at:
added +296/-1 3cat/resources/views/admin/catalog/products/edit.blade.php
The ticket was Fix configurable product can't saved. Administrators could not save a configurable product after the upgrade, and the fix was to add a 295-line file — a copy of Bagisto's own product edit view, pulled into our tree so we could change it.
I have been describing the vendored framework as a cost since July 2024, and in December I said the other half of that thought was less comfortable: vendoring means every one of its defaults is yours, including the ones you never read. February gives it a third face. The upgrade broke an upstream screen, and the fastest route back to a working admin panel was to take ownership of one more upstream file. Every upgrade makes the next upgrade bigger, and this is the mechanism, in one line of a diff.
Fourteen months later, the operator really was broken
Inside that livestream revert — a pull request titled Reverting lazy load live stream, which is about a header component and an API route — there is a new 121-line file with nothing to do with livestreams:
namespace P3cat\Rule\Helpers;
use Webkul\Rule\Helpers\Validator as WebkulRuleValidator;
/**
* Fix for Webkul Rule Validator: when operator is '{}' or '!{}' and condition
* value is an array with no matching item, $result was never set (undefined
* variable). We override validateAttribute to set $result = false before the
* foreach.
*/
class Validator extends WebkulRuleValidator
Read that comment against the last sixteen months. {} and !{} are the contains and does-not-contain operators — the ones a voucher uses to say applies to these products and except these products. When the condition value was an array and nothing matched, the framework's validator fell out of its loop with $result never assigned, and returned an undefined variable as a boolean.
In October 2024 I built voucher validation by hand-reading the cart rule engine's stored conditions, because the engine would tell me whether a rule applied but not why, and I wrote that it would drift. In December 2024 I reported that my evaluator was ignoring its own operators, so exclusions applied backwards, and I filed that under my own carelessness. In December 2025 I replaced the whole approach by extending the framework's CartRule helper and called it a correction to a fourteen-month-old mistake of mine.
It was mostly mine. It was not entirely mine. The exclusion operator in the code I had been deferring to was genuinely broken, in the specific case where nothing matched, and it took a framework upgrade surfacing an undefined-variable warning for anybody to find it. I do not get to revise the December 2024 article into innocence — my evaluator was wrong on its own terms. But the thing I eventually decided to trust instead had the same class of bug in the same operator, and I want that on the record next to the confession.
It also arrived under a title about a livestream header. I spent the whole of this series' 2025 audit complaining that significant work hides under wrong titles. Here I am doing it in 2026.
"Only 3 left" became a setting
549 additions, 241 deletions, nine files, and a migration that creates four new product attributes:
'code' => 'display_quantity',
'code' => 'hot_take_label_text',
'code' => 'hot_take_label_background_colour',
'code' => 'hot_take_label_text_colour',
Per variant, somebody in the admin panel can now type a number, a phrase, and two hex colours. The number renders like this:
<span class="display-quantity ..." data-default-text="{{ __('Limited Stock!') }}">
@if($selectedDisplayQuantity !== null)
{{ __('Only :count left!', ['count' => $selectedDisplayQuantity]) }}
display_quantity is an attribute. It is not the inventory quantity, it is not derived from the inventory quantity, and nothing in this diff connects the two. The sentence a customer reads — Only 3 left! — is a field someone filled in.
The ticket is candid about the goal: the existing static Limited Stock! message lacks the precision needed for high-value electronics, and per-variant thresholds and custom labels let the site create a more authentic sense of "Fear of Missing Out". The default label colour, if nobody picks one, is chartreuse on black.
I have been keeping a list. December 2023: a product page claiming a discount that was typed into the template, which I fixed and made this series' first article. November 2024: a Low Stock badge reading almost gone on products whose quantity was zero, which I shipped and wrote up as a softer version of the truth. March 2025: Rare Gem for anything with few sales. June 2025: pre-order SLAs, which I recorded as the thread resolving, because the shop stopped saying out of stock and started saying when it would arrive. January 2026: Great Condition replaced by 56-step checks, which I called the first entry where the fix made a claim more specific rather than removing it.
February undoes a fair amount of that. A hand-entered number, presented in the grammar of inventory, is further from the truth than Limited Stock! ever was — a vague claim invites no particular belief, and Only 3 left! invites a precise one. I want to be fair about what I can see: nothing stops operations keeping those numbers accurate, and for a shop with four warehouses and physical stores the real figure may be genuinely hard to compute per variant. What I can see in the code is that accuracy is optional, and the word in the ticket is "authentic".
I built it. It went in on 25 February with no objection from me, and the honest reading of my own list is that the honesty thread was never really resolving. It was oscillating, and I kept writing up the upswings.
Fifteen pull requests for one webhook
The month's largest piece of real work: a webhook system so that Bagisto can tell other software when an order changes. Fifteen pull requests, +3,397 / −1,093.
The ticket explains why, and it is the clearest business case I have been handed:
Currently, the team relies on manual refreshes in Bagisto to identify
new orders. This leads to:
- Especially when "2nd payments" occur much later than the initial order.
- Difficulty in visualizing "piles" of unprocessed orders.
- High risk of human error and missed fulfillment deadlines.
Somebody was sitting in an admin panel pressing refresh, and the split-payment feature I built in June 2025 made it worse, because a second payment can land days after the order. The answer is Airtable and n8n on the other end of an HTTP call, and on our end a webhooks table, an admin screen to manage endpoints, a data grid, event listeners, a queue, a worker, a log panel, filters for the log panel, and a button on the order view to fire one again by hand.
The part I would show somebody is the delay. The batch job sends events with a pause between them:
- sleep(10);
+ sleep(5);
Ten seconds became five on 11 February. Later the same day, a pull request titled Revert to 10 seconds delay. So: ten, then five, then ten again, within hours.
What is actually in that revert is the interesting bit, and it is not the delay:
- 'path' => env('APP_ENV') === 'dev' ? storage_path('logs/laravel.log') : 'php://stdout',
+ // stderr flushes immediately in CLI (e.g. queue:work webhooks) so logs reach
+ // CloudWatch; stdout can be block-buffered
+ 'path' => env('APP_ENV') === 'dev' ? storage_path('logs/laravel.log') : 'php://stderr',
+ 'tap' => [\P3cat\Logging\FlushStreamTap::class],
The logs were going to php://stdout, which is block-buffered, so a long-running queue worker's log lines sat in a buffer instead of reaching CloudWatch. We had been tuning a timing parameter on a background job whose output we could not see. The delay went back to ten seconds and a 68-line tap was added to flush the stream after every write.
That is the lesson of the whole cluster and I would put it above the feature. Two of the fifteen pull requests changed a number back and forth; one of them fixed the reason we could not tell which number was right. And the mechanism — sleep() inside a queued job, holding a worker slot for ten seconds per event, to be polite to somebody else's rate limit — is the sort of thing that works at today's order volume and will need rewriting at four times it.
Hotjar, a third time
On 4 February I added this to the master template. On 25 February I removed it.
<!-- Hotjar Tracking Code for https://3cat.my -->
<script>(function (h, o, t, j, a, r) { h.hj = h.hj || function () { ...
h._hjSettings = { hjid: 5170149, hjsv: 6 }; ...
Twenty-one days. The same site identifier as last time, byte for byte the same snippet.
In October 2024 I deleted that script as a hotfix and admitted in the article that the issue body was empty, so I could not reconstruct why. In February 2025 it came back for five days and went out again, and I wrote a whole article around it called The Month Hotjar Came Back for Five Days. February 2026 is the third cycle, in the same calendar month as the second.
The ticket asking for it says Enable Hotjar and nothing else. The ticket to remove it is mine, titled Disable Hotjar, with an empty body. A year after writing about not being able to reconstruct the reasoning, I filed the same empty ticket. Session recording clearly earns its place — March 2025 was almost entirely built out of what three days of recordings showed us. It also clearly costs something nobody has written down, because three times now somebody has decided the cost was not worth it and not said what the cost was.
The exclamation mark I did not predict
In December I flagged the stacking mechanism as the thing I would raise in review: the cart's single coupon_code field now holds a comma-separated list, and I wrote that a voucher code containing a comma would silently break the parsing.
On 3 February, a pull request titled Fixing voucher with exclamation:
- // Validate voucher codes format (alphanumeric, commas, underscores, hyphens only)
- const validCodesPattern = /^[A-Za-z0-9_,-]+$/;
+ // Validate voucher codes format (alphanumeric, commas, underscores, hyphens, exclamation)
+ const validCodesPattern = /^[A-Za-z0-9_,\-!]+$/;
Right family, wrong character, wrong layer. It was not the comma split that broke; it was a client-side allow-list regex, written to guard the string-encoded list, silently discarding any stored voucher whose code contained a punctuation mark I had not thought of. Marketing issued a code with an exclamation mark in it and the browser threw it away.
The fix adds ! to the permitted set, which fixes this code and not the next one. The same pull request also appended a missing trailing slash to the remove-coupon path and made the response parser tolerate a non-JSON body instead of throwing. Three small repairs, all of them downstream of deciding that a list could live in a string.
The popup got a trigger back
Six weeks after I stripped the exit-intent popup to a single ninety-second inactivity timer and wrote that I had spent nine months removing its triggers one at a time, I added one:
-// Handles popup exit with 90-second inactivity timer
+// Handles popup exit with 90-second inactivity timer + tab blur trigger
Switch away from the tab and back, and the popup fires on return. Nineteen additions, four deletions, and the file's eighth pull request.
In January I presented the reduction as a conclusion. It was a low point in a cycle. The January ticket said all other triggers for Exit Intent popup must be removed, and the February one asks for a trigger, and both are reasonable requests from the same people six weeks apart. I do not think anybody is being inconsistent. I think a feature whose job is to interrupt people has no stable answer to when it should interrupt them, and I have now written the opposite conclusion twice.
I published the thing you are reading this on
On 21 February I created dansday-com/dansday-main and on the 22nd I pushed eighteen commits, MIT licensed. It is the site these articles live on: a public front end that renders as a terminal window, a Laravel admin panel behind it, and an MCP endpoint so that an AI assistant can write and edit the content directly. Which is how this article got here.
Two things I left out of this article when I first wrote it, and found by auditing it. The first is that the repository has two pull requests dated 22 February, both titled Revamp & decouple frontend, each about 4,300 additions against 85,530 deletions across some 424 files — and neither of them merged. Number one was opened at 16:33 and closed at 16:41, eight minutes later, one minute after I opened number two, which is the signature of a pull request raised against the wrong base branch. Number two then sat open for three weeks and was closed on 14 March without landing. The eighteen commits went straight onto the default branch instead, which is what you do when you are the only person who will ever review them and you have decided the pull request is paperwork. It is worth writing down that the first two proposals on my own repository were abandoned, because the same month’s article at work is partly about a pull request that took fourteen days to review and needed four repairs anyway.
The commits from that one day are mostly the work of making something private fit to be read. Six API controllers deleted along with their routes, because they existed for an integration that no longer exists. Redis added for sessions and queues. A retry around the migration step in the Dockerfile so a container does not fail because the database was not ready yet. Automatic eager loading switched on for Eloquent, which is the same N+1 problem I have chased at work in May 2024, July 2024, November 2025 and January 2026, solved here in three lines because this codebase is small enough to allow it. Scrollbars hidden everywhere, which I mention because it is the sort of decision you make on your own project and would never get past review on somebody else's.
And one file I want to flag against myself:
class RunSetupOnFirstVisit
{
protected function runSeedIfNeeded(): void
{
if (General::find(1)) { return; }
$lock = Cache::lock('db_seed_once', 60);
if (! $lock->get()) { return; }
try {
if (General::find(1)) { return; }
Artisan::call('db:seed', ['--force' => true]);
} finally { $lock->release(); }
}
}
A middleware that seeds the database from a web request, so that somebody self-hosting this gets a working site on their first page load instead of a stack trace. Two months ago I published an article about an installer that was still reachable on a shop that had been live for two years, and about the discomfort of inheriting a default that could write configuration and create an administrator.
This is the same shape and I wrote it deliberately. The difference is the guards: it checks for one row and returns, it takes a lock so two simultaneous first visits cannot both seed, it re-checks inside the lock, it releases in a finally, and it can only seed — it cannot write an environment file or create a login. I think that is a defensible version of the pattern rather than a hypocritical one, but I am the wrong person to be sure, and having criticised somebody else's installer in December I should at least point at my own.
What February was
Also in the month: BNPL calculations updated and then their subtext corrected. Phone numbers normalised across the site and the order records, 428 additions and 548 deletions. The order export learned to include the item list and to print status titles instead of status codes, which is the third and fourth time I have touched that export since December. Google's purchase event was disabled for free reservations, six weeks after I did the same for Meta's, for the same reason. Bagisto's own payment methods were removed so that everything is forced through eGHL, which finishes the gateway consolidation I started in January by deleting iPay88 and senangpay. And a Lazada-style mobile variant selector was opened on the 26th and closed in March without landing.
Forty-three pull requests, and the two I will remember are the smallest and the largest. The largest is a framework upgrade that took two weeks to merge and immediately required four repairs, one of which was copying an upstream file into our tree so we could own it — the cost of vendoring, arriving on schedule.
The smallest is 121 lines of override with a comment explaining that the framework's exclusion operator returns an undefined variable when nothing matches. I have written three articles about getting voucher conditions wrong, each one more confident that the mistake was mine for not trusting the engine. The engine had the bug too, in the same operator, the whole time. That does not make me right. It does mean that when I said in December that the correction was to stop writing my own version of something the framework already does, I was drawing a lesson from a premise I had not checked.
And in between them, a text field that says Only 3 left!. I opened this series by making a product page stop claiming a discount it did not have, and twenty-six months later I shipped the tooling to type any stock figure you like into any variant. I am not going to pretend I did not notice.