Dansday

The Month I Deleted Half a Megabyte

Published on Jan 31, 2024

December had been about making a shop exist. January was about making it fast enough that people would wait for it to load.

Twelve pull requests, and the month had a different shape to December's. December was building furniture. January was three weeks of Core Web Vitals work, one feature that would not land for another two months, and a payment gateway I only got as far as saying hello to.

Half a megabyte at the front door

The homepage opened with a banner image. It was a PNG, and it weighed 475 kilobytes — roughly half a megabyte before a visitor on a Malaysian mobile connection had seen a single product. Below it sat a carousel of customer photos, fourteen of them, each a JPEG in the fifty-to-sixty kilobyte range. None of it was wrong exactly. All of it was expensive.

Google's Core Web Vitals had opinions about this, and those opinions affect search ranking, which affects whether anyone finds a shop at all. So the month arrived as three tickets in sequence: properly size images, then serve them in next-generation formats, then stop the whole thing from jumping around while it loaded.

The first pass was the boring one. Every image on the page got a lazy-loading attribute so the browser stopped fetching things nobody had scrolled to yet:

<img src="{{ $image }}"
     alt="{{ $alt }}"
     class="rounded-lg w-full object-cover object-center {{ $class }}"
     loading="lazy">

One attribute, added to the shared thumbnail component, and every card on the site inherited it. That is the dividend from December's decision to build components instead of pages — a one-line change reaching the entire homepage. It is also, as March would demonstrate, a change I applied too broadly.

Eight times lighter

Then came the format conversion, and this is where the numbers got satisfying. That 475KB banner became a 58KB WebP file. Same image, same dimensions, eight times lighter. The customer photos followed: 56KB to 16KB, 66KB to 26KB, fourteen of them down the list.

The interesting part was not the conversion — it was that the file extensions were scattered across a config file, and hardcoding .webp into every path would have made the next format migration just as painful. So I stripped the extension from the config entirely and let the template decide:

// config/home.php
'image' => '/images/homepage/customer-1',   // was: customer-1.jpg

// the template appends the format
<x-customer-stories image="{{ $item['image'] . '.webp' }}" ... >

Config describes which image. The template decides how to serve it. When AVIF becomes worth adopting, one string changes instead of forty.

The commit log from those days is not dignified. Image resizing, then Fixing Blur, then Height 480px, then Fix image quality, then a commit I named Photoshop magic: 400px. Compressing an image until it is small but not visibly mushy is not an algorithm you apply once. It is a dial you turn, look, and turn back.

The approach I threw away

On 16 January I opened a second, competing idea and eventually closed it. The next-gen conversion fixed the images already in the repository, but said nothing about the ones the team would upload through the admin panel next week. So I tried intercepting the upload instead — overriding Bagisto's product image repository so that every file an administrator added was re-encoded on the way in:

if (Str::contains($file->getMimeType(), 'image')) {
    $manager = new ImageManager();

    $image = $manager->make($file)->encode('jpg');

    $path = $this->getProductDirectory($product) . '/' . Str::random(40) . '.jpg';
}

Bound into the container so that core Bagisto code kept calling the class it already knew about, and got mine:

$this->app->bind(
    \Webkul\Product\Repositories\ProductImageRepository::class,
    ProductImageRepository::class
);

Ninety-four lines, and I closed it unmerged on 8 February after it had sat open for three weeks. It solved the wrong half of the problem: it would have compressed every future upload while doing nothing for the catalogue already on disk, and it forced everything to JPEG at exactly the moment the rest of the site was moving to WebP. Two solutions to one problem, and shipping both would have left the codebase with two conventions and no reason to prefer either.

I mention a closed pull request because the ones you abandon are part of the month too. This one taught me where in the stack the answer belonged, which is why the answer that shipped was a build-time concern and not a runtime one.

A page marketing could write

The largest thing I opened in January was not an image ticket at all. Category listing pages needed search-optimised copy underneath the products — real prose, written and edited by the marketing team, without a developer in the loop. 304 additions across fifteen files.

Bagisto's category model had nowhere to put it, so the column went onto the translation table rather than the category itself, so the copy can differ per language:

Schema::table('category_translations', function (Blueprint $table) {
    $table->text('bottom_text')->nullable();
});

Then the part that took longest to find. Bagisto's own category controller passes an explicit list of fields through to the repository, and a column that is not on that list is silently ignored — you save the form, the page reloads, and your text is simply gone. No error. So I extended the core controller rather than editing it:

class CategoryController extends CoreCategoryController
{
    public function store(CategoryRequest $categoryRequest)
    {
        $category = $this->categoryRepository->create($categoryRequest->only([
            'locale', 'name', 'parent_id', 'description', 'slug',
            'meta_title', 'meta_keywords', 'meta_description',
            'status', 'position', 'display_mode', 'attributes',
            'logo_path', 'banner_path',
            'bottom_text',
        ]));
    }
}

One string added to two lists, in a subclass. Editing the vendor directory would have been quicker and would have been erased by February's framework upgrade. Almost everything I did in this codebase that survived, survived because it was an override rather than an edit.

The admin form got a rich-text editor, which created the actual design problem: styling HTML that a human wrote. Content from an editor arrives with whatever headings, lists and links the author felt like using, and Tailwind's preflight strips all of it back to plain text. So a hundred and five lines of stylesheet went in to give editorial content a typographic scale of its own:

.seo-content h1 {
    font-size: 28px;
    line-height: 32px;
    letter-spacing: -0.28px;
}

.seo-content a {
    color: inherit;
    text-decoration: underline;
    font-weight: inherit;
}

The link rule is the one I would defend. An inherited colour means the copy stays legible wherever it is dropped, instead of a hardcoded blue that works on white and disappears on the dark band.

While in that file I found the listing page's stylesheet loading the product page's Tailwind configuration:

- @config "./config/tailwind.pdp.config.js";
+ @config "./config/tailwind.plp.config.js";

Copy-paste from whoever created the file, and it meant every listing page had been compiled against the wrong set of design tokens since the day it was made. Nobody had noticed, because the two configs mostly agreed. Mostly.

I opened that pull request on 15 January. It merged on 13 March, which is a story that belongs to March.

Smaller fights

A chat widget was floating over the sticky product specification bar on desktop, each element correctly positioned in isolation and colliding in practice. And colour variants were not rendering for certain products — the kind of bug where the template is fine and the data has a shape you did not anticipate.

The store cards needed a pass too. Long addresses were stretching cards to different heights and dragging their buttons out of alignment, so the text got clamped instead:

@php $textClass = 'mt-4 font-medium text-sm/5 line-clamp-4'; @endphp

And a pair of labels that had been duplicated to say different things at different breakpoints:

<span class="block xl:hidden">Directions</span>
<span class="hidden xl:block">Get Directions</span>

became one string pulled from the translation file. A word typed into a template is a word that cannot be translated later, and this shop would eventually be running in a second country.

Then the homepage banner became a carousel, which meant touch handling on mobile, which meant discovering that a single-banner carousel should not be swipeable at all. There is a commit called Disable touch event and another called Temp fix, and I stand by neither.

The gateway that could say hello

The month ended somewhere else entirely: wiring Senangpay, a Malaysian payment gateway, into Bagisto. Payment integration is mostly about proving you are who you say you are, and that the amount has not been altered in transit. Senangpay does this with a hashed signature:

$hashed_string = md5(
    $this->secretkey
    . urldecode($this->detail)
    . urldecode($this->amount)
    . urldecode($this->order_id)
);

The return trip matters more. When the gateway redirects the customer back, you cannot trust the query string — anyone can type a success status into a URL. So you rebuild the hash from the values received and compare:

if ($hashed_string === urldecode($request->hash)) {
    // genuinely from the gateway
} else {
    return 'Hashed value is not correct';
}

Without that comparison, a customer could claim any order was paid. With it, forging one requires the secret key.

I should be honest about what that pull request actually was. It pointed at the sandbox endpoint, and the constructor held hardcoded values — an iPhone 12, RM 1600.00, order 123456 — each with a comment reading use product name, use product price, use product checkout id. It was a spike: prove the handshake works, leave signposts for the real wiring. The last pull request of the month was deliberately unfinished.

Those four urldecode calls are also, though I had no way of knowing it in January, the reason nobody could buy an iPad in April.

What January was

Twelve pull requests. Half a megabyte deleted from the front door, an approach abandoned on purpose, a stylesheet that had been reading the wrong config since the day it was written, and a payment gateway that could at least say hello.

The thing I would point at is the override in the category controller. It is one line in a subclass, and it is the reason a marketing team could write their own copy — and the reason that feature was still standing after February replaced the framework underneath it.