Eight pull requests in August, six of them merged. Two threads ran through the month: handing the operations team the keys to their own pages, and a production bug reported in the vaguest possible terms — the site is sometimes stuck.
Pages the operations team could make
The month opened with a request I liked. The CMS could store a page; it could not publish one. Anything the operations team wrote sat in the database until a developer wired up a route for it, which meant every utility page — the trade-in list, the payment methods explainer — was a ticket.
The ask came with an unusual constraint attached, and the constraint is the interesting part:
These pages are only for operational use and is not intended to be
discovered by Google for indexing & ranking.So publishing and being discoverable had to become two separate decisions. A column carried the second one:
Schema::table('cms_page_translations', function (Blueprint $table) {
$table->boolean('to_sitemap')->default(false);
});Default false. A page created in the CMS is live at its slug and invisible to search engines unless somebody explicitly says otherwise. Getting that default the wrong way round would mean every internal scratch page the team ever made turning up in search results for a shop trying to rank on used iPhones — and you cannot un-index things on your own schedule.
The routing side needed a catch-all, because a slug can now be a product, a category, or a CMS page and the URL does not say which. That went into a new SlugHandlerController, with a page controller and a category controller beside it. Twelve hundred additions across twenty-four files, and most of the deletions are the good part: the frontend product controller lost 55 lines, an auxiliary controller lost its 19 lines of hardcoded page handling, and the sitemap controller lost 44 lines to a proper SitemapService.
That sitemap extraction is the shape of the whole feature. The old controller knew which pages existed because someone had typed them into it. The new service asks the database, and the database now has a column that answers.
One line of that pull request was an nginx rewrite rule, and it needed a same-day follow-up to use an absolute URL for the page redirect. A single character of configuration, deployed to staging, wrong — the part of a feature that no amount of local testing reaches.
An editor that showed what it would look like
Giving people a rich text editor creates an immediate second problem: they cannot see what they are making. The CMS editor rendered content in the editor's own styling, and the site rendered it in the site's, and the two had never been introduced. Text that looked correct while being written arrived on the page with its headings and spacing wrong.
My first attempt at a preview was a small pull request on 7 August — 53 additions, three files — that I closed. What replaced it was a 424-line editor component, configured to load the site's own stylesheets so the editing surface and the published page finally agreed, with a preview button that posts the unsaved content to a route and opens the result in a new tab.
It shipped on 14 August and broke immediately, in the way that only front-end wiring does:
- document.getElementById('preview_page').addEventListener('click', function () {
+ const previewButton = document.getElementById('preview_page');
+
+ if (previewButton) {
+ previewButton.addEventListener('click', function () {The preview button exists on the edit screen and not on the create screen. Calling addEventListener on null throws, and because that call sat inside the editor's setup callback, the exception took the entire editor down with it — not just the preview. A missing null check on one element meant the create page had no rich text editing at all.
That is the failure mode I have come to watch for. The bug is not in the feature you added, it is in the assumption that the feature's neighbours are always present. A staging fix went out the same day.
The pull request that changed its name
I need to correct something I wrote about July.
Last month I described a query-detector pull request — the one where I installed a tool to find N+1 problems instead of guessing at them, and eager-loaded six relations onto the similar-products block. I said it never merged, because the pull request I opened on 31 July was closed on 28 August without merging.
The code shipped. On 13 August the same branch was reopened under a different ticket and a different title — filed as the CMS text styling work — and merged on 14 August. I compared the two pull requests file by file: identical set, identical line counts, twenty-three files and 2,061 additions in both. The original was closed a fortnight later as the duplicate it had become.
So the eager loading is in production, and it got there bundled with a rich text editor, because both changes lived on one branch and the CMS ticket was the one moving. That is an honest picture of how work actually lands and a bad habit besides: two unrelated concerns in one diff means the reviewer approves the thing they were asked about and the other thing rides along. It also meant a set of framework config files that had been gitignored got committed in the same breath, which is a third unrelated change.
Sometimes after clicking checkout CTA
On 21 August I picked up the hardest ticket I had been given. The report was not a stack trace, it was three accounts of the same feeling:
1. Sometimes after clicking checkout CTA.
Eg flow: HP -> PDP -> Checkout -> click CTA -> Gateway -> back to PDP
-> click CTA -> Gateway -> back to PDP. Back to HP -> PDP same product
-> checkout -> click CTA = stuck
2. Experience from user: All pages, I'm unable to checkout, can't load
PDPs, homepage is also stuck during these incidents.Note what makes that hard. It is intermittent, it depends on a sequence rather than a state, and one of the reporters describes the whole site being slow while another describes one button hanging. Those may be one bug or three.
I wrote my assumption into the ticket before writing any code, which I would do again:
Issue: Data of cart is deactivate after order created then when user goes
back to checkout using back button from gateway, data cart is not
triggered/created againPlacing an order deactivates the cart, which is correct. Then the customer presses the browser back button from the payment gateway — not a link, not a form, an ordinary back button, which no server-side flow gets to intercept — and lands on a checkout page whose cart no longer exists. Click pay again and it hangs.
Rendering a page should not write data
The fix was to stop the checkout page from building a cart in order to draw itself.
The old controller created one on arrival, then read the page's numbers back out of it:
- $cart = Cart::getCart();
- if (!$cart) {
- $cart = Cart::addProduct($product, $params);
- }
- $cart->all_items[0]['warranty'] = $this->cartCalculation->calculateWarranty(...);A GET request that writes a row. Visiting a page — refreshing it, arriving on it from the back button, being crawled on it — created or mutated persistent state. Every one of the reported symptoms is downstream of that.
Afterwards the page reads from the product it is already displaying:
'checkLimit' => $this->isWithinLimit($product->min_price),
'cardInstalmentsTotal' => $product->min_price,
'isCartPurchasable' => $this->cartValidator->isProductCheckoutable($product)That last line needed the validator to learn a smaller question. It could only answer is this cart purchasable, which requires a cart to exist; now it can also answer is this product checkoutable, which does not:
public function isProductCheckoutable($product): bool
{
if (!$product->getTypeInstance()->isSaleable()
|| ($product->getTypeInstance()->isSaleable() && $product->stock_qty <= 0)) {
return false;
}
return true;
}The cart is now created when the customer commits, which is the only moment that ever justified it. Rendering became a read.
The other half of the slowness was a lookup. Finding a product by its URL slug had been going through the framework's generic repository; it got a purpose-built one that fetches the images it is about to need in the same query:
public function findBySlug(string $slug): ?ProductContract
{
return $this->model
->with(['variants.images'])
->whereHas('attribute_values', function ($query) use ($slug) {
$query->where('attribute_id', function ($subQuery) {
$subQuery->select('id')->from('attributes')->where('code', 'url_key');
})->where('text_value', $slug);
})
->first();
}Ninety-three additions, eighty-six deletions, nineteen files. It merged on 29 August after eight days, with test scenarios I wrote out as checklists in the ticket and a colleague independently verifying the back-button case.
Two small things in that diff
Two lines in that pull request are worth more than their size.
The first is a correction to something I described in February. I had written then about finding a stale order surviving in the session between checkout attempts, and clearing it with one call at the right point in the cart lifecycle. The call was clearing the wrong key:
- session()->forget('order');
+ session()->forget('order_id');The session key was order_id. forget('order') removes an entry that was never there and returns perfectly happily, so for six months I believed I had fixed something I had not touched. That is the most expensive kind of mistake in this whole series — not a crash, but a silent no-op that closes the ticket.
The second is the spinner. The checkout button showed a loading spinner and hid it again when the response said the cart was not purchasable, but on the success path it set window.location.href and left the spinner running until the browser navigated. If navigation was slow, the customer watched a spinner that was waiting on nothing:
window.location.href = data.redirect_url;
+ this.hideSpinLoader();The ticket was called site periodically unresponsive/stuck on loading. Some fraction of that report was a genuine architectural problem with writes on a GET, and some fraction was a spinner nobody had told to stop.
The same diff also deleted quantity=1& from every checkout link. A hardcoded parameter that the new controller no longer reads, on a shop that has only ever sold one device at a time.
Whose intent wins
The last thing I merged in August was a sort function. Promotional banners had been ordered by end date, falling back to the marketing team's explicit priority only when two dates were equal. That is backwards: it means the field labelled priority is consulted last.
- if ($promoEndA->eq($promoEndB)) {
- return $a['priority_order'] <=> $b['priority_order'];
- }
- return $promoEndA <=> $promoEndB;
+ if ($a['priority_order'] === $b['priority_order']) {
+ $endDateComparison = Carbon::parse($a['promotion_end']) <=> Carbon::parse($b['promotion_end']);
+ ...
+ }
+ return $a['priority_order'] <=> $b['priority_order'];Priority first, then soonest to expire, then most recently started. A person's stated intention outranks the system's heuristic, and the heuristic breaks ties. When you give non-developers a control, the control has to actually be in control.
What August was
Eight pull requests, six merged. An operations team that can publish pages without me and without accidentally publishing them to Google, an editor that shows what it is making, and a checkout page that stopped writing to the database every time somebody looked at it.
The thing I would point at is the shape of that last one. The ticket said the site was slow and sometimes stuck, and the answer was not a cache or a bigger instance. It was that a page had been doing something a page should never do, and had been getting away with it since the day I built it in December. The second attempt at that fix, which I opened on 29 August, I closed the next day. The original issue is still open.