Work
A2 Hosting, LLCApr 2023 - Apr 2025

GoCart

A2 Hosting's checkout — the page every sale went through — rebuilt as a Laravel application with no database of its own, talking to a custom WHMCS addon over HTTP. Two years of work, sixteen months live, and a front end owned by a different team on a different cadence, which turns out to be the whole story.

PHP 8.2LaravelWHMCSStripePayPalBladeBootstrapPlaywrightGitHub ActionsDeployerSentry

GoCart was A2 Hosting’s order form. Every sale the company made — shared hosting, reseller, managed WordPress, VPS, dedicated servers — went through it. It replaced WHMCS’s built-in cart with a standalone Laravel application at cart.a2hosting.com, talking to a custom WHMCS addon over HTTP for products, prices, promotions, domains, authentication, and the order itself.

We architected it. A third-party agency, Objectbrewery, built the first working version — the WHMCS addon and a proof-of-concept Laravel app — and we took it from there: production infrastructure, the deploy pipeline, monitoring, the security controls, and two years of leading development. The front end went to A2’s marketing team, who owned the design and drove the changes.

The first commit lands April 5, 2023. It went live a little over five months later, on September 19, 2023, and took every order A2 Hosting received for the next sixteen months. The last lands January 28, 2025.

The technically interesting thing about GoCart is that it has no database. The organizationally interesting thing — the one that actually determined how it went — is that the page taking the money had two owners on two different clocks. Most of what follows is downstream of that.

A checkout that owns nothing

Zero migrations. Zero models. The entire Laravel application is two controllers and six routes that do anything. The addon lives in A2’s own WHMCS repository, and it creates exactly two tables: gocart_metadata, which is an order ID and a key and a value, and gocart_sessions, which is a token and a blob. Neither holds anything you couldn’t throw away and rebuild from WHMCS.

Everything else is WHMCS’s. Products, prices, currencies, tax rules, promotions, TLD pricing, user accounts, client accounts, permissions, orders, invoices, payment methods. GoCart renders a form and posts it.

The entire integration surface is one class. Paths holds the six URLs the application can construct — index.php?m=go_cart&a=get_product, &a=order, &a=pricing, &a=domain, &a=get_locale, &a=authenticate — and nothing anywhere else in the codebase builds a WHMCS URL. On the other side, the addon’s entry point is a switch on ?a= with those same six cases and a default that returns a 500. Adding an endpoint meant a file in api/ and a line in the switch. Reading one function told you the complete API.

This is the same prohibition we worked under on TurboHub — never query the WHMCS database, only its API — but it held here without strain, and the reason is worth naming rather than claiming as discipline. TurboHub eventually shipped forty-nine migrations because it had to answer a question WHMCS couldn’t: which of our customers is running a vulnerable plugin. A checkout has no such question. It needs to know things only at the moment of checkout, and WHMCS knew all of them. Holding a line is easy when nothing pushes on it.

The order contract

Orders are built through a fluent builder: set a user or register one, add products with billing cycles and config options and custom fields and a domain, add a promotion, add a payment method, attach metadata, accept the terms. Then submit() posts the whole thing as JSON.

The detail worth borrowing is the method sitting next to it. dump() returns exactly the body submit() would send, without sending it, and every order path in the controller logs the dump before submitting. When an order failed in production, the log had the precise payload that failed, not a reconstruction.

On the addon side, order.php validates and then hands everything to WHMCS’s own localAPI('AddOrder'). Config options and custom fields go over as base64_encode(serialize($array)), because that is what WHMCS’s own order form posts and what AddOrder is written to receive. It is not our format and it is not a good format. It was the right one, because inventing a better one would have meant reimplementing the thing on the other side of it — which is the whole failure mode the architecture existed to avoid.

The seam that didn’t get designed is one layer up. The controller reconstructs structured data by prefix-matching request keys: option_3 becomes config option 3, custom_field_12 becomes custom field 12, group_7 becomes an upsell in group 7. It works, it was fast to write, and it means the wire format between the Blade template and the order builder is a naming convention that exists in two files and no document.

Testing was allocated deliberately, and reading the allocation tells you something. The library that talks to WHMCS has roughly a line of test for every line of code. The application itself has none: tests/Feature/ExampleTest.php and tests/Unit/ExampleTest.php are the Laravel stubs, one edited to assert that / returns a 302. On the addon side there are nine test classes, and every query is written twice — a MySQL branch and a SQLite branch — specifically so the module could be tested without a WHMCS install behind it.

So the code that talked to WHMCS was covered line for line, and the code that rendered the page was not covered at all. Given which of those changed weekly, that is exactly backwards. The last two sections are what it cost.

Money is not a form field

The order sequence is create, then check, then charge, and the ordering is the design.

A registered client with a prior fraud order is refused before anything is created, and the error names the earlier order number. Past that, the addon calls AddOrder first and runs WHMCS’s fraud check against the resulting order second. If the check fails, a templated fraud email goes out through WHMCS’s own email system. If the order comes back flagged as Fraud, the endpoint returns success with payment_skipped: true and never touches the card.

An order that exists and hasn’t been charged is a support ticket. A card charged against an order that shouldn’t exist is a refund, a chargeback, and a conversation with a payment processor. Creating first and charging last means the failure mode is the recoverable one.

Fraud scoring is only as good as the IP address it sees, and GoCart sat behind Cloudflare and a load balancer. The authenticator forwards the client address four times — X-Forwarded-For, CF-Connecting-IP, X-Real-IP, X-Client-IP — and the order payload carries clientIp explicitly rather than letting the addon read REMOTE_ADDR. Belt and braces, because we did not control which header WHMCS’s fraud module would read, and the failure mode of getting it wrong is scoring every customer in the world as the same machine in Michigan.

Payments were Stripe, PayPal, and bank transfer. Stripe takes a token, attaches a payment method to the client, and charges the last invoice on the order. PayPal maps to a billing-agreements gateway. Bank transfer creates the invoice and stops.

Stripe’s failure handling is the honest part. If attaching the payment method fails, or the charge fails, the endpoint still returns success — with payment: false and the underlying message attached — because the order exists. Returning failure would have been a lie that lost a real order to make the response code tidier.

Pricing runs currency-aware end to end: product prices, config option prices, domain registration and transfer and renewal prices, all converted, with tax calculated from the client’s country, state, and postcode. The tax path is wrapped in a stopwatch that logs if calculation takes more than three seconds. Nothing acts on it. Somebody put it there because it did.

The promotion nobody typed

The pricing endpoint doesn’t wait for a coupon code. For every product in the cart it queries every live promotion that applies to that product and billing cycle — unexpired, started, under its usage cap — computes the effective discount in the customer’s currency, keeps the largest one found across the whole cart, applies it to every product it’s valid for, and returns the code in the response so the field can fill itself in.

The customer never types anything. The cart finds the best available deal and applies it. For a hosting company running a dozen overlapping promotions across product lines and seasons, that is a meaningfully better checkout: nobody reaches the last step wondering whether a code exists that they don’t have. The same pass runs across every billing cycle, so the cycle selector shows what each option would actually cost after discount rather than before.

Then there is the exclusion list, which is a hardcoded PHP array of two promo codes skipped during best-promo selection. Both are real promotions that shouldn’t auto-apply — one is an upgrade offer, one a conference code. It is a marketing decision compiled into a WHMCS module, changed by a pull request and a deploy. The right place for it was a checkbox on the promotion in the WHMCS admin, next to all the other promotion settings. That never got built, so every time a promotion needed excluding, someone edited the checkout’s pricing logic.

Someone else’s plugin ecosystem

WHMCS is a plugin ecosystem, and hooks in it can end a request.

A2 ran a domain blocker addon — separate, older, also ours — that hooks domain lookups and die()s with its own JSON body when a domain is blocked. The shape it emits matches nothing the GoCart API returns, and it arrives from a code path GoCart never called.

So there is a method in the controller whose entire job is to recognize that shape and convert it into an ordinary failure, and whose docblock links to the file in the other repository that produces it. Fifteen lines, one link, and a customer who types a blocked domain into the checkout gets told the domain is unavailable instead of getting nothing.

That method is the honest cost of building on a platform whose extension model is “any addon may terminate any request.” There is no contract to program against, so you program against observed behavior and leave a pointer for whoever finds it next.

Affiliate tracking has the same character. The cart reads Post Affiliate Pro’s visitor and affiliate cookies and forwards them in the order payload. The addon, before calling the tracking function, writes them back into $_COOKIE and sets them as response cookies — because the tracking call runs inside that same request and reads the superglobal directly. Assigning to $_COOKIE to make a library see a value is not something anyone wants to be doing. It is also the only thing that works when a tracking library assumes it is running in the browser’s own request and it isn’t. Commission attribution was the requirement; that was the shape the requirement had.

Ad attribution rode along the same path: Google’s gclid, Microsoft’s msclkid, Facebook’s fbclid, Matomo and Analytics cookies, and a landing-page cookie, all forwarded from the cart into WHMCS so attribution survived the hop between domains.

Ship, announce, verify

Deploys are releases. A manual workflow tags master with a timestamp and cuts a GitHub release with generated notes; publishing that release triggers Deployer against a single production host. The production environment file is committed encrypted, decrypted during deploy with a key kept in 1Password, and renamed into place.

Then the deploy announces itself: gh release view piped through jq turns the release notes into a Slack message and curl posts it to the project channel. The team learned what shipped from the release notes, which meant the release notes had to be right, which meant they were.

Rollback is deleting the GitHub release. The deleted event triggers dep rollback. There is no runbook, no separate command, and no chance of reaching for the wrong one under pressure — the thing you would do anyway is the thing that fixes it.

Pull requests ran three checks: php -l across every file, PHPUnit in parallel, and a sensitive-parameter check. That last one is a shell script that diffs the PR, greps added lines for function parameters named password, username, email, apikey, secret, or token, and fails the build if #[\SensitiveParameter] isn’t on them. Same control we ran on TurboHub, same reasoning: the attribute keeps values out of stack traces and out of anything that serializes one, and hanging enforcement off a naming convention costs nothing.

Sentry tells its own story. Traces sampled at 20% at launch, halved to 10% in April 2024, set to zero that October. That is not tuning. That is a feature being quietly switched off, because a form that re-priced itself on every change produced trace volume nobody was reading.

The /heartbeat route is a keepalive. The order form is one long page and a Laravel CSRF token expires while a customer is filling it in. On launch day — September 19, 2023 — the token refresh shipped at 12:03, was reverted an hour later, and was back in the tree by the end of the day. It is still there in the final commit. A checkout that logs people out mid-form is invisible in every test and obvious to everyone using it.

The part unit tests couldn’t see

Here are the two numbers side by side. The entire Laravel application is about nineteen hundred lines of PHP. One Blade partial, footer-scripts.blade.php, is also about nineteen hundred lines — inline jQuery driving the whole form. Domain lookup, live price refresh, billing cycle changes, config options, upsells, the password strength meter, Stripe.js, the payment modal.

So the tested code was the part that rarely changed. The untested code was the part that changed constantly, was owned by the team with the most reason to change it, and was the only part a customer could actually experience.

Marketing owning the front end was correct. They ran the promotions, the copy, the layout, the seasonal campaigns. Routing all of that through a development queue would have made the checkout slower to change than the market it was selling into, which for a hosting company in 2023 was not a trade worth making. But two teams shipping into one repository on different cadences, with the entire test suite sitting on the half that didn’t move, has one predictable outcome. A layout change moves a selector. A submit handler stops firing. Nothing in CI notices, because CI is exercising an HTTP client library.

The first signal was a customer telling us they couldn’t give us money.

That is the worst signal available. It arrives late, it arrives through support, and it is fundamentally unquantifiable — you hear from the one person who wrote in, not from the ones who closed the tab. It means the business found out about a revenue outage from the people losing money to it.

None of which is a failure of anyone writing front-end changes. It is a failure of a system that let a change reach the checkout with nothing between it and a customer.

A2 Playwright was the answer, built in August 2024 — a multi-project Playwright suite covering GoCart, WHMCS, and TurboHub, each with its own specs, fixtures, and page objects.

The wiring is the part that mattered. GoCart’s production deploy, right after announcing itself to Slack, mints a GitHub App installation token and triggers the test repository’s GoCart workflow with live_orders=true and notify_slack=true. Every production deploy of the cart ends with a real order placed through the live cart — faked customer details, a randomly selected product from A2’s actual catalog, bank transfer, submitted, and followed through to the WHMCS order confirmation page.

Not a staging order. Not a smoke test against a mock. A real order, on production, on every deploy, because the failure being defended against was “the order form does not take orders,” and there is exactly one way to know that. The same suite runs as a canary every morning at 8:13 Eastern. The GoCart specs cover order submission, the password strength meter, billing cycle pricing, domain lookup, dedicated IP pricing, UI presence, and URL routing.

Failures go to Slack through a custom reporter — capped at five per run, retries skipped, routed to a cart-specific channel with a link to the spec file that failed. Minutes, with a pointer to the code, instead of a support ticket next week.

The page object is honest about what it’s driving. Submitting waits three seconds and then clicks with force: true. Both of those are what you write when you’re testing a jQuery form you did not build and cannot instrument, where “the button is ready” is not a state you can query. They aren’t good. They’re what the surface allowed, and a checkout tested from the outside badly beats a checkout not tested from the outside at all.

It was built to be handed off — three internal docs covering the workflow triggers, adding a new project, and the GitHub App authentication for cross-repository dispatch — and a QA engineer picked it up in October 2024 and kept building on the same patterns.

What we’d do differently

Playwright was the right emergency response and the wrong permanent fix. It detects breakage. It doesn’t prevent it. The actual fix was a boundary — a component API, a documented set of stable hooks, anything that gave marketing a surface they could change freely and a surface they couldn’t reach by accident. We built the safety net at the browser because by then it was the only place left to build one, which is another way of saying the seam was drawn in the wrong place two years earlier. Nineteen hundred lines of inline jQuery in a Blade partial isn’t a code smell. It’s an org chart.

Smaller ones. The product allowlist is a regular expression in routes/web.php — 34 hardcoded product IDs, plus four hardcoded redirects for products that got renamed. Launching a product meant a code change and a deploy of the checkout, for a fact WHMCS already had somewhere to store. The option_ / custom_field_ / group_ prefix format was never written down outside the two files that implement it. The promo exclusion list should have been a column.

What held was the absence. Two years, not one migration, and never once a question of whether the cart and the billing system disagreed about a price. Every number a customer saw came from the system that was going to charge them. Alongside it: creating orders before charging cards, linting for sensitive parameters, rolling back by deleting a release. Small controls, none of which needed revisiting.

The last commit lands January 28, 2025, weeks after A2 Hosting was acquired by World Host Group. GoCart had been the company’s order form for sixteen months and had never had a database, which is a strange thing to be proud of and the correct thing to have built. A checkout is not a system of record. It is a form, a contract, and a very short window in which you cannot afford to be wrong.