<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="/css/simple-atom.xslt"?>
<feed xmlns="http://www.w3.org/2005/Atom">

  <title>Death.au's Domain posts tagged PHP</title>
  <subtitle>Thoughts, stories and ideas.</subtitle>
  <link rel="self" href="https://death.id.au/tag/php/atom.xml" type="application/atom+xml" />
  <updated>2026-08-05T06:30:37Z</updated>
  <author>
    <name>Death.au</name>
  </author>
  
  <id>https://death.id.au/tag/php/</id>

  
  <entry>
    <title>Just Bricks</title>
    <id>https://death.id.au/cv.20/</id>
    <updated>2026-07-30T00:00:00Z</updated>
    <published>2026-07-30T00:00:00Z</published>
    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><h1>Just Bricks</h1>
<p>Just Bricks is one of Australia's larger independent LEGO retailers. When I picked up
their store it was running CS-Cart 4.5.2 on PHP 7.1 — a 2017-era platform, years past
security support, sitting on an upgrade path that everyone involved had been carefully
not looking at for a long time.</p>
<p>I took it to CS-Cart 4.20.1 on PHP 8.3.</p>
<h2>Fifteen upgrades, in order, on a live store</h2>
<p>There is no jump from 4.5 to 4.20. Each version will only offer you the next one, so
the work is fifteen sequential major-version upgrades, plus two PHP major-version jumps
sequenced against which versions the platform would actually run on. All of it on a
store carrying 201,000 orders, 89,000 customers, 7,397 products and a 3.2 GB database,
which had to come out the other side intact.</p>
<p>The interesting decision was what to do with the collisions validator. CS-Cart warns you
which of a site's customisations each upgrade is about to overwrite, and you can skip
the check. Skipping it is much faster, and it silently destroys the customisations that
make the store the store. So I didn't — I used the validator's output at every step as a
list of things to review and re-apply. That is most of where the time went, and it's the
reason nothing was quietly lost.</p>
<p>Along the way: a Phinx migration that failed because of MySQL strict mode, the CS-Cart
4.11 product-variations data migration, missing primary keys across roughly 120 tables
that only surfaced under a newer MySQL, and a store-reopen mechanism that changed
underneath me partway up the path. I also delivered WebP image optimisation and rebuilt
the theme as a proper child of <code>responsive</code> rather than a fork, so the next upgrade is a
smaller job than this one was.</p>
<h2>A brand-new theme</h2>
<p>Along with the updates, we designed a brand-new, modernised look for the website, and
it was up to me to take it from a set of figma designs to a fully-fledged theme. This
didn't just involve exporting CSS, but building new layouts, sourcing and adding images,
extrapolating for pages that <em>weren't</em> designed, and liasing with the client to make
sure they were happy with the end result.</p>
<p>And a lot of tweaking.</p>
<h2>Then the anti-fraud suite</h2>
<p>With the platform current, the brief moved to card fraud.</p>
<p><strong>PayPal Advanced Card Payments.</strong> The ask was a cardholder-name field without a
billing-address block, plus card metadata recorded against orders so staff could review
suspicious ones. Working through it, I established something the client hadn't expected:
under PayPal's standard Smart Buttons flow, guest card entry is routed through an
auto-created PayPal account, so the merchant never receives card data at all. That's why
the metadata wasn't there. Both requests turned out to be the same problem, and moving
to Card Fields solved both at once. Built with 3-D Secure, Apple Pay and Google Pay
wallet capture, and funding-source recording — entirely through hooks, under a house
rule of no core edits.</p>
<p><strong>Order IP geolocation.</strong> Each order's stored IP is resolved to a country and city and
checked against the billing and shipping country, flagging mismatches for review. I
built it on the MaxMind GeoLite2 database already bundled with the platform, so it added
no runtime dependency, no per-query cost, and no customer data leaving the server —
with an automated, checksum-verified, atomically-swapped database refresh. CS-Cart's own
<code>fn_get_country_by_ip()</code> turned out to be IPv4-only, because it round-trips the address
through <code>long2ip(ip2long())</code>; I bypassed it so IPv6 orders resolve properly.</p>
<p><strong>Checkout hardening.</strong> Inline on-blur validation, and blocking payment when a guest
checkout email already belongs to an account — offering an in-place login instead.</p>
</div></content>
    <link rel="alternate" href="https://death.id.au/cv.20/" />
    <summary type="html">A nine-year platform migration on a live LEGO store, and the anti-fraud suite built on top of it.</summary>
  </entry>
  
  <entry>
    <title>Canvas Factory</title>
    <id>https://death.id.au/cv.22/</id>
    <updated>2026-06-12T00:00:00Z</updated>
    <published>2026-06-12T00:00:00Z</published>
    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><h1>Canvas Factory</h1>
<p>Canvas Factory print wall art — you pick an image, pick a size, and they print and ship
it. They run CS-Cart across Australian, New Zealand and US storefronts, and I was
brought in to implement GA4 and Google Tag Manager ecommerce tracking across all of it.</p>
<p>Which sounds routine, and wasn't, because of one structural fact: <strong>the store doesn't
sell products.</strong></p>
<h2>Selling sizes, not products</h2>
<p>The images come from external licensing providers — Getty, Alamy and others — at a scale
approaching 100,000 images. They aren't CS-Cart products. They don't have product IDs,
they aren't in the catalogue, and they can't be, because the catalogue would be
meaningless at that size and would need to track someone else's inventory.</p>
<p>What the store actually sells is a <em>size of a print of an image</em>. GA4's ecommerce model,
meanwhile, very much wants a product with an ID and a category. So the job became
designing a mapping: a composite item-ID scheme of <code>{source}_{art_image_id}</code> that stays
stable across sessions and providers, and a category hierarchy that GA4 would accept and
that the client's marketing team could actually read in a report.</p>
<h2>Two bugs worth mentioning</h2>
<p><strong>An <code>add_to_cart</code> event that had never once fired in production.</strong> The listener existed
on the upgraded site, so it worked in every environment anyone tested in. It was missing
entirely from live. Nobody had noticed, because the absence of an event doesn't look
like anything — the reports just quietly had a hole where the most important step of the
funnel should be.</p>
<p><strong>Event integrity across an iframe boundary.</strong> The canvas configurator — where the
customer crops and positions their image — is an iframe maintained by a third-party
developer, so events had to cross a <code>postMessage</code> handoff between two codebases, only
one of which I controlled. Debugging that meant establishing, for each event, whether it
was fired at all, fired twice, fired with the wrong payload, or fired into a listener
that had already been torn down. It's a good reminder that in analytics work the bug is
almost never in the analytics.</p>
<p>All of the tracking work shipped, and the client and marketing company involved were
very happy with the results.</p>
</div></content>
    <link rel="alternate" href="https://death.id.au/cv.22/" />
    <summary type="html">Ecommerce analytics for a store that doesn&#39;t sell products — it sells sizes.</summary>
  </entry>
  
  <entry>
    <title>Area Safe Products</title>
    <id>https://death.id.au/cv.21/</id>
    <updated>2026-03-25T00:00:00Z</updated>
    <published>2026-03-25T00:00:00Z</published>
    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><h1>Area Safe Products</h1>
<p>Area Safe Products sell road, traffic and public-safety equipment — bollards, wheel
stops, street furniture — across two brands: Area Safe Products and Astra
Street Furniture. Both storefronts run from a single CS-Cart backend.</p>
<p>I rebuilt their checkout and took it live in late March 2026.</p>
<h2>Three ways to buy the same thing</h2>
<p>Most eCommerce checkouts have one job: take the money. This one has three, because the
customers aren't all the same kind of customer.</p>
<p>A member of the public buying a wheel stop pays by card. A council or contractor
specifying street furniture wants a <strong>quote</strong>, not an order. An established trade
customer wants to place the order <strong>against their existing account</strong> and be invoiced.
Same catalogue, same cart, three completely different outcomes — and the sales team
works the quotes and account orders that come out the other end.</p>
<p>The complication was that none of this sat on standard data structures. Order type and
flow were carried on non-standard fields, layered over a
heavily modified and by-then deprecated multi-step checkout addon. It was not a
greenfield build; it was a rebuild threaded through several years of accumulated custom
code, which is the harder and far more common real-world problem. I ended up writing a
report for my director explaining <em>why</em> the checkout was so resistant to change, which
was as useful to the client as the code.</p>
<h2>What went into it</h2>
<ul>
<li><strong><code>swim_checkout_options</code></strong> — a custom addon managing order type and the branching checkout flow.</li>
<li><strong>A weight-and-freight rate engine</strong>, built and loaded via SQL across the <code>shippings</code>, <code>destinations</code> and <code>shipping_rates</code> tables, with GST applied per rate area.</li>
<li><strong><code>swim_forklift_charge</code></strong> — a custom addon that automatically applies a tailgate-unload surcharge when any item in the cart exceeds 80 kg. If you're buying something that needs a truck with a lift on it, the checkout works that out rather than the customer discovering it later.</li>
<li><strong>Stripe</strong> with daily settlement, plus Purchase Order and Quote payment methods.</li>
<li><strong>The Stair Nosing Calculator</strong> — a small HTML/JS widget on the product page. Stair nosing is sold by length and you need a specific quantity for a specific staircase, so the customer enters their measurements, the widget computes what they need, and it goes straight into the cart. The client built this with an AI tool and then asked me to hook it up to actually add items to the shopping cart.</li>
</ul>
<p>Later in the year I also spent time on the platform's reliability — the store had been
going down repeatedly, and tracking that down turned into a separate piece of work
involving PHP-FPM pool limits, memory saturation, and a fair amount of arguing with my
own monitoring about what it wasn't telling me.</p>
</div></content>
    <link rel="alternate" href="https://death.id.au/cv.21/" />
    <summary type="html">A three-path B2B checkout and freight engine, across two storefronts running from one backend.</summary>
  </entry>

</feed>