If you wanted to sell used iPhones in Malaysia in late 2023, you needed a page that could convince someone to spend RM 999 on a phone that had belonged to a stranger. That was the problem waiting for me when I joined 3cat — a company that buys back Apple devices, repairs them, and resells them with a one-year warranty through four stores in Kuala Lumpur.
The codebase was two weeks old. Someone had already laid the pipes: a Laravel install, Docker builds pushing to ECR, a CircleCI pipeline, Terraform for the infrastructure. What none of it had was a shop. No product page, no homepage, nothing a customer could look at. Five of us were building that from nothing, and my share of it started on 13 December. Nine pull requests later the month ended with something you could buy from.
Week one: the furniture
My first ticket was four features in one pull request — more devices, the Ask Teega block, customer stories, header and footer. 307 additions across 35 files, which tells you something about the pace. A homepage needs a header before it needs anything clever.
I built the header, the footer, and the sections between them: a device carousel, a wall of customer testimonials, a help block. Then I spent the back half of that week doing something less glamorous — tearing what I had just built into reusable Blade components. It felt like going backwards. It was the single most useful thing I did all month.
The trick was giving every component sensible defaults, so a half-populated page still rendered instead of exploding:
@props([
'image' => '/images/placeholder.png',
'alt' => 'Store',
'title' => 'Default store title',
'phone' => '123435',
'address' => 'default address',
])
<x-partials.image-thumbnail image="{{ $image }}" alt="{{ $alt }}" />
<div class="text-xl font-bold">{{ $title }}</div>From then on we assembled pages instead of writing them — a store card, a product card, an accordion, a button in every variant the designers had drawn. That pull request also swapped the favicon and absorbed the first round of review feedback, which is the first of many reminders that shipping and being done are not the same verb.
The product page
On 20 December I opened the biggest pull request of my month: the product detail page. 1,461 additions across 91 files in the first attempt, which I closed and reopened as a 632-line version across 36 files. I described that below as understanding the shape of it, which is generous about how long the understanding took. Two more pull requests followed over the next week refining it.
Alongside the page itself came 100 lines of homepage configuration, 43 lines of Tailwind theme extension for the new design tokens, and a 56-line store-locations component shared between the homepage and the product page.
Then the moment I still think about. The design I inherited looked finished. A red badge promising you would save RM 500 today, a price of RM 999, a struck-through RM 1,199 beside it. Every one of those numbers was typed directly into the template. Every phone in the catalogue claimed the same discount on the same price, whether it was true or not.
Making it honest meant deriving the figures from the pricing layer and formatting them as ringgit:
@define $saving = $product->guestPrice
? $product->guestPrice['regular_min_price'] - $product->guestPrice['min_price']
: 0;
@define $save = $saving ? Option::formatMYR($saving) : '';
@define $price = Option::formatMYR($product->guestPrice['min_price'], 0);
@define $beforePrice = Option::formatMYR($product->guestPrice['regular_min_price'], 0);Note the ternary guarding guestPrice. A product with no price record returns null, and subtracting a key from null throws — so the fallback to zero is the difference between a missing price and a 500 error on a product page.
That part was straightforward. The interesting question was what the page should do when there is no discount at all, because a badge cheerfully announcing you will save RM 0 today is worse than no badge. So I taught it restraint:
@if(!empty($saving))
<div class="box-discount bg-destructive-500">
Save {{ $save }} today
</div>
@endif
@if($price < $beforePrice)
<span class="line-through text-neutral-300">{{ $beforePrice }}</span>
@endifTwo conditionals, and the page stops lying. A page that looks designed is easy. A page that stays truthful when the data is boring takes a little more care.
I also found this waiting in the template, dumping raw product JSON onto the screen:
@foreach($product->variants as $variant)
@dump(json_decode($variant))
@endforeachSomeone's way of asking what on earth is in this thing. I worked out the answer, wired the real image through getBaseImageUrlAttribute(), and cleaned up after them.
Behaviour, not just layout
The fourth product-page pull request was about what things do rather than how they look. Outbound links to WhatsApp and store directions got rel="nofollow" and target="_blank", so customers stopped falling out of the site and search engines stopped following links we did not vouch for. It touched twelve files to do it — every call-to-action variant, the store card, the Ask Teega block, the footer, the WhatsApp widget — because the behaviour belonged in the components rather than at each call site. Hardcoded values moved into config files at the same time, so the next person changing a phone number would not have to hunt through templates.
Two hours and eighteen minutes
I want to put a real number on that reopening, because it is the first instance of something this series comes back to repeatedly.
The first attempt was opened at 03:02 and closed at 05:21. The replacement was opened at 05:23, two minutes later. Two hours and eighteen minutes between deciding to build the product page one way and deciding to build it another — not a night's sleep, not a review comment, just long enough to look at 91 files and lose confidence in them.
What actually changed is worth more than the line counts. The first version put 91 files' worth of images into the root of the image directory. The second renames them into folders — homepage/stores/store_lowyat.png and its siblings — splits a single config file into two, and drops 44 lines from the homepage configuration. It is not a cleaner version of the same work; it is a smaller one, with the structure decided before the volume.
Two years later, in December 2025, I merged a pull request that moved images out of the root of that same directory into global/, pdp/ and static/, and the reason the diff was 140 lines instead of 15 was that every reference had to move with them. So the instinct was right in the first week and it did not hold for the next twenty-four months.
The habit itself is the thing to flag. Closing your own pull request and reopening it is how I worked from the beginning, and it recurs: three attempts at one upgrade on a single afternoon in July 2024, two identical pull requests for the same ticket in September 2024, the same 460-line change opened twice across January and February 2025. In July 2024 I wrote an article called The Month I Opened the Same PR Three Times and treated it as a discovery about myself. It was not new. It was two hours into the second week.
The FAQ was still in Latin
The title of this article is about making the prices real, so it is worth showing what else was in the repository at the time. The second product-page attempt deletes a forty-line config file, and this is what was in it:
'faqs' => [
'main_heading' => 'FAQ',
'accordian-1-title' => 'Lorem ipsum dolor sit amet, consectetur adipiscing',
'accordian-1-text' => 'We have a range of apple used products that are
highly rated based on Japanese grading system.',
],
Placeholder Latin in the configuration of a shop that was two weeks from having a product page, with the key misspelled — accordian — and a real sentence sitting underneath it. So in the same month I was deriving prices from the pricing layer so the page would stop claiming a discount it did not have, the frequently-asked questions were a Lorem ipsum string and one answer about Japanese grading.
I fixed the prices because a wrong number is a lie about money. The Latin was obviously temporary and everybody knew it, which is exactly why it was still there. Both were honesty problems; only one of them felt like one at the time.
The rest of that file is the four shops — Plaza Low Yat, The Mines, Wangsa Walk, Paradigm Mall — each with a phone number, a full address, a WhatsApp number and a Google Maps link, all written into config. That is the right place for them in week two. It is also the beginning of a thread that runs for two years: every store opening after this one meant editing a config file, the New Store badge that got attached to each new outlet and eventually meant nothing because almost every store had it, and the store data that by 2025 needed a command to enrich it.
One more thing in that pull request, four lines long:
<?php
/*
Config for pdp
*/
An empty config file, created and committed with nothing in it but a comment saying what it was for. I find that oddly honest. It is a note to whoever came next about where the product page's settings were going to live, written before there were any.
The defaults that would have shipped
I quoted the component defaults above approvingly, and I still think extracting components was the right call in week one. The specific defaults deserve a second look:
'title' => 'Default store title',
'phone' => '123435',
'address' => 'default address',
Those are not safe fallbacks. They are plausible-looking rubbish. A store card missing its data does not fail, does not render blank, and does not draw attention to itself — it renders a shop called Default store title with a phone number of 123435, on a page a customer is reading. The failure mode I chose was the quiet one.
Compare it with the other decision from the same fortnight, the one I was pleased with:
@define $saving = $product->guestPrice
? $product->guestPrice['regular_min_price'] - $product->guestPrice['min_price']
: 0;
That ternary is there because a product with no price record returns null and subtracting a key from null throws. I checked. I wrote the guard, and I wrote about why.
Which makes the years that followed harder to explain rather than easier. In March 2024 I read categories[0] on a product with no categories. In July 2024 I called first() on a collection that could be empty. In December 2024 I used the result of a find() without checking it found anything. In February 2025 I indexed the output of json_decode() by a key that was not always present. I have named that as a recurring habit four separate times, each time as though I were discovering it.
The standard was set in the second week of the second month of my career here, in a template, with a comment explaining the reasoning. It was not a lesson I had yet to learn. It was one I stopped applying.
The punch list
The final ten days were three pull requests of pure correction. Section alignments across the whole site. An Additional Fixing pull request spanning seventeen files. Then a footer pass: missing elements, wrong spacing, a copyright line that was wrong, store names rendering incorrectly.
Somewhere in there is a commit called Revert immediately followed by Additional Fixing, which is the most honest thing in my git history. The last pull request of my year was a three-line fix called Fix rebase — the unglamorous tax on working in a repository where four other people are also moving fast.
On 22 December I tinted the staging environment a different colour so nobody would mistake it for production again. Small change, and it has saved somebody an embarrassing moment at least once.
What December was
Nine pull requests. A homepage, a product page, a component library, working links, and prices that finally told the truth.
The thing I would point at is not a feature. It is the decision in week one to stop building pages and start building parts — because every month after this one moved faster for it. I joined a repository that was two weeks old and left it able to sell something.
Reading the diffs back two years later, though, the month is less of a clean start than I made it. The product page was rebuilt from scratch two hours after I first proposed it. The images went in the wrong folders and had to be moved, and would need moving again in 2025. The FAQ was still Latin. Components shipped with defaults that would have rendered a fake phone number to a customer. And the one thing I got unambiguously right — guarding a lookup that could return nothing — is the thing I then spent four separate articles rediscovering.
None of that changes what December was for. It does mean the first article in this series was the one most inclined to describe the month as it was intended rather than as it happened, which is the failure the rest of the series was written to avoid.
Revised: at just over a thousand words this was the shortest article in the series and the least specific about its own diffs. Added from the original pull requests: the actual timing and content of the product-page rebuild, the placeholder text and store data in the deleted config file, and the component defaults set against the null guard written the same fortnight. Nothing was removed.