January ended with a payment integration that could say hello to a sandbox. February was about turning that into something that could take real money from a real person without losing their order — and, in the middle of it, replacing the framework underneath.
Fifteen pull requests. Two of them were larger than everything else I shipped in my first three months combined.
Deleting the spike
The first thing I did was throw away January's work. The whole controller — the hardcoded iPhone 12, the RM 1600.00, the fake order number 123456. Forty-nine lines, gone in one commit. A spike has done its job when you understand the handshake, and keeping it around only invites someone to build on scaffolding.
What replaced it needed a way to exist in production without customers finding it. The checkout button had been hidden behind an environment check:
@if(config('app.env') !== 'production')Which works, and is useless. It means the one environment where you most need to test the thing is the one place you cannot. So I gave it a second door:
$isCheckoutable = config('app.env') !== 'production'
|| $request->get('checkoutable');Off everywhere in production, unless you append a query parameter. The team could walk the full payment flow on the live site, against the live gateway, while every ordinary visitor saw a page with no checkout button at all.
The upgrade nobody sees
Then the largest change I have ever shipped: Bagisto v2.1.0. 132,922 lines added, 56,980 removed, 1,708 files touched.
Most of that is vendor code and lock files, and it would be dishonest to claim credit for lines a package manager wrote. The work is in the parts that break. Every middleware, every service provider, every config file in the application had to be reconciled against the new version's expectations — app/Http/Kernel.php, EventServiceProvider, AppServiceProvider, the exception handler, the console kernel.
The dependency changes tell the story better than the file count:
- "cviebrock/laravel-elasticsearch": "^10.0"
+ "elasticsearch/elasticsearch": "^8.10"
+ "openai-php/laravel": "^0.7.8"
+ "pestphp/pest-plugin-laravel": "^2.1"The search client moved from a Laravel wrapper package to the official Elasticsearch library, which meant the 187-line Elasticsearch config was rewritten down to 47. Laravel Octane arrived with 223 lines of new configuration. A DataTransfer package was added to the autoloader for import and export handling.
An upgrade like this produces no feature. Nobody thanks you for it. What it buys is the ability to keep taking security patches and to use packages that have already dropped support for the version you were on — and it is far cheaper to do while the application is four months old than a year later. I opened it as a draft first, then shipped it merged into the transaction-visibility work once staging held up.
Making money move
The payment flow itself took most of the month, spread across several pull requests. An invoice needs generating. A transaction needs recording against the order. A payment can fail, and the customer has to land somewhere that explains it rather than a blank page. The invoicing pull request alone deleted a mock payment page, removed a stale order controller, and moved 88 lines into the payment gateway controller where they belonged.
The commits from that fortnight are a straight line of small problems: Adding transaction, Routes for confirmation, Update the validation, Adding callback for error payment. There is a separate one-line pull request called Fix staging payment failed redirection, because the failure path pointed somewhere that only existed in production.
The callback handler is where I learned to be paranoid. The original accepted a response if it merely mentioned an order:
if (!$this->request->has(self::ORDER_ID)) {
return false;
}That is not validation, it is optimism. A payment callback arrives as a URL in the customer's browser, which means anyone can type one. I rewrote it to demand every field the signature depends on — order id, status, message, transaction id, and the hash itself — before considering the response genuine. Miss one and the hash cannot be rebuilt, and an unverifiable callback should be refused rather than guessed at.
The two-character bug
Customers were reporting that their billing address had been saved as their shipping address. The checkout form was building its payload like this:
"use_for_shipping": "false",A string. The string "false", which in PHP is non-empty and therefore true. The backend read every billing address as also-use-this-for-shipping and overwrote what the customer had typed. The fix was to stop sending words where a boolean was expected:
"use_for_shipping": 1, // shipping
"use_for_shipping": 0, // billingWhile in there I found a stale order surviving in the session between attempts, so a customer who abandoned a checkout and started again could inherit fragments of the previous one. One session()->forget('order') at the right point in the cart lifecycle closed it.
The migration that could be undone
Transactions had been recorded as failed when they were merely unfinished. A customer who reached the gateway and did not pay is pending, not failed, and the distinction matters when you are separating abandoned baskets from broken payments. Rewriting the existing rows was one line. The rollback needed more thought:
public function down(): void
{
$rollbackDate = '2024-02-27';
DB::table('order_transactions')
->where('status', 'pending')
->whereDate('created_at', '<=', $rollbackDate)
->update(['status' => 'failed']);
}A naive reversal would flip every pending transaction to failed, including ones created legitimately after the migration ran. Scoping the rollback to rows that existed beforehand keeps the undo honest. A migration you cannot safely reverse is a one-way door, and you rarely notice until you need to walk back through it.
A document somebody can print
The largest thing I wrote by hand in February was not the framework upgrade. It was an invoice.
A shop that takes money has to be able to produce a document proving it. Bagisto ships an invoice screen, but it renders Bagisto's branding and Bagisto's layout, and an invoice is the one page a customer keeps. So the pull request on 28 February carried two templates written from scratch — 434 lines for the printable PDF, 395 for the admin view — plus a controller, a route file and a migration. 985 additions across seventeen files.
The controller is almost nothing, which is the point:
class InvoiceController extends CoreInvoiceController
{
public function printInvoice(int $id)
{
$invoice = $this->invoiceRepository->findOrFail($id);
return $this->downloadPDF(
view('admin.sales.invoices.pdf', compact('invoice'))->render(),
'invoice-'.$invoice->created_at->format('d-m-Y')
);
}
}Extend the core controller, override the two methods that choose a view, register them on our own route file. The PDF machinery, the repository and the permissions all stay where Bagisto put them. This is the same override-don't-edit habit as the category field in January, and by the end of February I had stopped thinking of it as a technique and started thinking of it as the only way to work in somebody else's framework.
The detail in that template I did not expect to need was the font:
@php
/* main font will be set on locale based */
$mainFontFamily = app()->getLocale() === 'ar' ? 'DejaVu Sans' : 'Noto Sans';
@endphpA PDF has no fallback font stack. If the embedded typeface has no glyph for a character, you do not get an approximation, you get an empty box on a financial document. The locale has to pick the font before the page renders.
The note the customer wrote
The same pull request carried a small round trip I liked more than the invoice. Customers could type a remark at checkout — a delivery instruction, a request — and there was nowhere for it to go. So it got a column:
Schema::table('orders', function (Blueprint $table) {
$table->text('note')->nullable();
});A repository method to store it, and a panel in the admin order screen to display it:
public function storeOrderNote($data, $id): Order
{
$order = $this->find($id);
if ($order) {
$order->note = $data;
$order->save();
}
return $order;
}Four small pieces — a column, a write, a read, a box on a page — and the difference is that something a customer took the trouble to type now reaches a human who can act on it. Before this it was collected by a form and discarded on submit, which is worse than not asking.
What the browser was being told
Buried in that same diff is the change I would flag in a code review today. The checkout controller had been answering the browser like this:
return new JsonResponse([
'success' => false,
'message' => 'Error: Cart::getCart()->hasGuestCheckoutItems()'
]);An internal method signature, handed to the client as an error message. Two other endpoints were worse — they attached the full output of Shipping::getShippingMethods() and Payment::getSupportedPaymentMethods() to every response, success or failure, whether or not anything needed it.
None of it was secret exactly. All of it was free reconnaissance: the shape of the internals, the payment providers configured, the names of the checks being run, published to anyone with the network tab open. The fix was deleting four lines. Debug output has a way of becoming the response format if nobody removes it before launch, and this was three weeks before we started taking real money.
The rest of it
Store images were swapped for the four outlets. The order confirmation page gained a delivery partner and tracking number for EasyParcel, which also meant opening up the Tailwind colour palette that had been restricted to a hand-picked set.
And the month ended on a revert, though a smaller one than the pull request number suggests. On 29 February I reverted exactly one line of the previous day's work: the struck-through price beside the free one-year warranty in the checkout summary. I had changed it to read from the cart, and it went back to being derived as a flat tenth of the product price:
{{ Option::formatPrice(((float) $cartItems[0]['price']) * 0.1, 0) }}My last pull request of February is called Reverting the changes, and it is a one-line diff. The invoice, the note and the response cleanup all stayed.
I am not proud of that line. Two months earlier I had spent a week making the product page stop announcing invented discounts, and here is a warranty valued at ten percent of the product price because a real figure was not available yet and the design needed a number in that slot. It is the same compromise I had already argued against, and I made it anyway to unblock a page. The honest version is that hardcoded numbers do not leave a codebase because someone learns a lesson. They leave when someone gives the template a real source to read from, and in February I did not have one.
A second folder in a monitoring script
One more thing happened in February that is not in the 3cat repository at all, and I should account for it because a month is a month.
Since mid-2023 I had been maintaining a small Python URL checker — a script that walks a list of pages, requests each one, and records what came back. It lived in a public repository of mine with a single folder in it, named after iPrice. On 29 February I added a second folder, named after 3cat, and put 288 lines in it:
used-iphone-14-plus-512gb-starlight
new-iphone-15-pro-max-1tb-natural-titanium
topup-rm500
topup-rm400
upgrade-topup
used-iphone-13-pro-max-1tb-sierra-blueOne product slug per line, hand-typed. Two things in that list are worth noticing, because neither appears anywhere else in this article: the shop sold brand-new iPhones alongside the used ones, and it sold top-up credit in fixed denominations. I spent the month building a checkout that took real money and I have described it entirely in terms of used devices, because that is the part I was looking at.
The commit is one line of work and it is the first sign that the shop had become large enough that somebody needed to check, on a schedule, whether its pages were still there.
What February was
Fifteen pull requests, and 288 lines in a monitoring script. A checkout that could take real money, refuse a forged callback, print a document, keep what the customer wrote, stop narrating its internals to the browser, and put the billing address in the billing field — running on a framework version that would still be supported in six months.
The upgrade is the one I would defend in an interview. 1,708 files, no new feature, nothing a customer could notice, done in the month it was cheap rather than the month it became urgent.