← All posts

Autovõti: the jump from WooCommerce to Laravel

A tour of the Laravel 13 and React storefront replacing my WooCommerce shop for car key covers: a finder for brand and key shape, search by photo, a checkout that locks and reserves, webhooks that aren't trusted, refunds that can say "I don't know", and conventions enforced by tests.

Autovõti sells covers for car keys. Dozens of colourways, from black with gold edges to white marble and red carbon, each one cut for a specific key: a Kia K7, a Lexus L3, a Volkswagen V6. It is a small Estonian shop with a very specific problem, and it runs on WordPress and WooCommerce, the way small shops do.

This is the story of the jump from WooCommerce to Laravel: a Laravel 13 and React storefront I have been building to take over from the WooCommerce shop at autovoti.ee. It is my own shop, so there is no client brief and no deadline except my own patience, which meant I could build it the way I think commerce software should be built. This post walks through the codebase: the stack, the conventions, the parts I am proud of, and the parts that are not finished yet.

In development · replacing WooCommerce

474commits
1,025PHP files in app/
~5,900Pest tests
~2,500Vitest tests
100%line and type coverage
  1. Finder
  2. Product
  3. Cart
  4. Checkout
  5. Montonio
  6. Paid

Why leave WooCommerce

WooCommerce got the shop open, and for a while that was all it needed to do. The trouble is that a car key cover is not a generic product. The question a customer arrives with is not "what do you sell?" but "which of these fits my key?", and the answer depends on two things WooCommerce has no opinion about: the car brand and the shape of the key. The old shop answered it with categories and patience. Brands became parent categories, key shapes became child categories, and the same cover had to appear under every brand that uses that key.

The catalogue data tells the story. When I exported it, there were 24 car makes at the top level, plus a gift-card category, WooCommerce's default "Uncategorised" and an empty "New group" nobody remembered making. Under the makes sat 73 key-shape categories, such as K7, AX3 and F12, but only 61 distinct shapes, because a shape had to be filed again under every make that uses it: V2, V3, V4 and V11 each appear under Volkswagen, Škoda and Seat. There were 61 products and 837 variants, every one priced at €14.99, because a colourway is a variant and each key shape comes in up to fifteen of them. The data was right. The shop just couldn't use it well.

So the rebuild is organised around the question the customer actually asks. The home page leads with a finder: pick your car brand, pick your key shape, and the shop shows only the covers that fit. If you do not know what your key shape is called, and most people don't, you can take a photo of your key instead. More on that later.

Making the jump

Leaving WooCommerce meant bringing everything it knew with me. The catalogue came out of the old shop as two JSON files, one for categories and one for products, and two seeders turn them into the new schema: makes and key shapes become categories, colourways become values of a single "design" attribute, and every label keeps the translations the old shop had. Car makes get their logos by matching names against an open car-logo set, with a list of alternative spellings, so "Mercedes Benz" and "Mercedes-Benz" find the same badge.

URLs are the part of a migration that quietly costs you traffic, so the new shop treats them as data. Redirects live in the database with their origin (manual, slug change, migration or CSV import), a hit counter and a check for redirect chains. Every organic 404 is logged, while requests for static files are ignored, so after the switch the admin shows exactly which old addresses people are still arriving at, and each one can be pointed somewhere useful. Rename a product later and a redirect from the old slug is created for you.

The stack, in one table

It is a single Laravel application with an Inertia front end. There is no separate API, no headless split and no second deployable for the storefront; the server renders Inertia pages, and React takes over in the browser.

Layer What it uses
Backend Laravel 13, PHP 8.5, Postgres with pgvector, Redis for queues and cache
Front end Inertia 3, React 19 with the React Compiler, TypeScript 7, Tailwind 4
Components shadcn/ui in its Base UI flavour, Vite 8, server-side rendering
Routing and i18n Wayfinder and veltix/wayfinder-locales for typed, localised routes
Search Laravel Scout with Typesense, plus visual search with laravel/ai
Payments Montonio (bank links, cards, BNPL) and manual methods
Shipping Omniva parcel lockers, DPD courier and pickup in Tallinn
Tax EU VAT with OSS, reverse charge and VIES checks via veltix/tax-engine
Real time Laravel Reverb, with a Hocuspocus and Yjs server for co-editing

A few of those deserve a sentence each. The React Compiler is on for the whole front end, so components are memoised by the compiler rather than by hand. When the linter gained the compiler's rules, it flagged 43 violations. 41 were fixed and two are left as warnings, and three of them were real bugs: a dialog that never rotated its idempotency key, so a second manual payment silently returned the first; a component that read a ref during render; and the menu builder pushing undo history twice under StrictMode.

The shadcn components come from the Base UI flavour rather than Radix, so composition goes through render props. And every storefront URL is localised: the product page lives at /product/… in English and /et/toode/… in Estonian. The segments are declared on each route and compiled into typed helpers by veltix/wayfinder-locales, a package of mine that also compiles the PHP language files into TypeScript catalogues.

Redesigning it in one place

The storefront has been React since February, but its look has changed a lot since then. In September I redesigned all of it in a single interactive studio, and that design is what's being applied to the React code now. Every screenshot in this post comes from the studio, not from a running shop.

The studio is one HTML page with the whole shop inside a device frame, and a panel on the side that changes the design live: eight palettes taken from real cover designs, four type pairings, corner radii, button shapes, three hero layouts, three header styles, card styles, density and even the page and theme transitions.

The design studio: every page of the shop in a device frame, with the design system on the right, down to palettes lifted from real cover colourways.
The design studio: every page of the shop in a device frame, with the design system on the right, down to palettes lifted from real cover colourways.

The studio covers 36 pages, not just the ones that look good in a portfolio. The catalogue, a brand page and a key-type page. The product page, search, gift cards and the physical stores. The cart and the checkout, the order confirmation and a guest order lookup. The whole account area, from order history to two-step verification. Two designs each for the 404 and 500 pages. And all 23 transactional emails.

Designing the dull pages with the same care was the point. A shop is judged at checkout and in its emails far more than on its home page, and those are exactly the pages that get designed last and least when you work page by page. In the studio, every screen comes from the same tokens.

A detail I like: the colourway swatches are not dots. Each one is a tiny key cover silhouette with its plated edge, gold or silver, drawn in CSS, so "white marble with gold edges" looks like what it is.

The home page design in dark mode, with the Onyx and gold palette.
The home page design in dark mode, with the Onyx and gold palette.

Finding a cover

The finder is two levels deep: a grid of brand logos, then a grid of key-shape photos with a hover preview, so you can compare the buttons on your key with the picture. On a phone it opens as a sheet.

What I care about here is what happens when you pick something. It is not a new page load. The finder asks the server for exactly three props and resets the product list, and everything else on the page stays where it is:

const navigateToCategory = (slug: string) => {
    router.get(
        HomeController.url({ locale }, { query: { category: slug } }),
        {},
        { only: ['sidebar', 'variants', 'filters'], reset: ['variants'] },
    );
};

const clearCategory = () => {
    router.get(
        HomeController.url({ locale }),
        {},
        { only: ['sidebar', 'variants', 'filters'], reset: ['variants'] },
    );
};

That is Inertia's partial reloads doing what they are good at. The URL still changes, so the result can be shared or bookmarked, and the back button works. But the payload is only the sidebar, the filters and the first page of variants. The product grid itself is a deferred prop with a skeleton and a manual "load more", so the page is interactive before the heaviest part of it has arrived.

The key-type page design: every cover for the Kia K7 in one place, filterable by finish and design.
The key-type page design: every cover for the Kia K7 in one place, filterable by finish and design.

A product page that remembers your colourway

Each cover is one product with a variant per colourway. The variant picker shows each option as a thumbnail of that exact colourway, taken automatically from the image conversions, or as a colour dot, or as a text button when neither makes sense. Every option carries aria-pressed, and there is an optional hover zoom.

When you choose a colourway, the URL changes with it. The product page becomes /product/{slug}/{attribute}/{value}, preferring the colour attribute, and it is written with history.replaceState, so it does not flood the back button. Send someone the link and they land on the same cover you were looking at, not the default one. Reviews and recommendations load as deferred props, and a link to #reviews opens the reviews section on arrival.

The product page design for a Kia K7 cover: colourway swatches, a fit guarantee and an offer for the spare key.
The product page design for a Kia K7 cover: colourway swatches, a fit guarantee and an offer for the spare key.
The design on a phone: home, product and checkout.
The design on a phone: home, product and checkout.

Conventions the code can't break

The part of this codebase I would show another developer first is not a feature. It is how the conventions are written down: as tests.

The backend follows a few simple rules. Business logic lives in Actions: small final readonly classes with a single handle() method. There are 158 of them. Controllers do three things: authorise, turn the validated request into a data object, and call an Action. They contain no domain logic and stay away from the DB facade. Reads that need more than a relationship go into Query classes. Anything with more than one implementation, such as a payment gateway, a shipping cost calculation or a tax strategy, goes behind a contract. Data crosses layers as typed Data objects rather than loose arrays. There are 81 of those.

Rules like that tend to stay true only while the person who wrote them is watching. Here they are enforced by Pest architecture tests: Actions are final, readonly and expose only handle(); controllers are final and don't use DB; Actions, Queries and Models never import Request or Inertia; every enum is string-backed; every file declares strict types; env() is never called outside config. Where a rule has exceptions, they are a short, named allowlist in the test. Sixteen admin controllers still wrap their collaborative saves in a transaction, for example. An exception is a visible decision, not a quiet drift. Some conventions can't be expressed with arch(), so they are ordinary tests using reflection. This is my favourite:

test('models do not declare $fillable or $guarded (global unguard + validated input is the convention)', function (): void {
    foreach (glob(app_path('Models/*.php')) as $file) {
        $class = 'App\\Models\\'.basename($file, '.php');

        foreach (['fillable', 'guarded'] as $property) {
            $declaringClass = new ReflectionProperty($class, $property)->getDeclaringClass()->getName();

            expect($declaringClass)->not->toBe($class, "{$class} must not declare \${$property}; writes must use validated()/Data input.");
        }
    }
});

Mass-assignment protection is off globally (Model::unguard()), and this test makes sure nobody quietly switches it back on for one model. The protection is supposed to come from somewhere else: every write goes through validated() input or a Data object. A $fillable array is a second, weaker copy of that rule, and two copies of a rule drift.

The front end has the same idea in a 591-line Vitest file. Routes may only be imported from the generated Wayfinder functions, every page must have a layout, every icon-only button needs an accessible label, and state-changing fetch calls must send CSRF headers. My favourite trick in that file is the positive control:

it('never imports routes from @/actions or @/routes -- only @/wayfinder', () => {
    const offenders = src.filter((f) =>
        /from\s+['"]@\/(actions|routes)(\/|['"])/.test(
            readFileSync(f, 'utf8'),
        ),
    );
    expect(offenders).toEqual([]);
});

it('positive control: @/wayfinder imports actually exist', () => {
    // Guards against the assertion above passing vacuously (e.g. if the
    // wayfinder convention were silently abandoned rather than followed).
    const wayfinderUsers = src.filter((f) =>
        /from\s+['"]@\/wayfinder\//.test(readFileSync(f, 'utf8')),
    );
    expect(wayfinderUsers.length).toBeGreaterThan(0);
});

A grep-based test that finds no offenders might mean the rule is followed, or it might mean the pattern no longer matches anything. The second test proves the first one is looking at something.

One more convention is unusual enough to mention. Pint is configured to strip comments that aren't type annotations. That sounds hostile, but it moves the explanations to where they last: commit messages, which are long and specific, and test names, which are executed. A comment can go stale without anyone noticing. A test name that stops being true fails.

Checkout as a script

Checkout is where a shop earns or loses trust, so it is the most carefully built part of the codebase. Placing an order is one Action, and it reads like a list of the things that have to happen, in order, inside a single database transaction:

public function handle(Cart $cart, CheckoutData $checkoutData): Order
{
    $order = DB::transaction(function () use ($cart, $checkoutData): Order {
        $cart = Cart::query()->whereKey($cart->id)->lockForUpdate()->first();

        throw_if(! $cart || $cart->items()->doesntExist(), RuntimeException::class, 'Cart is empty.');

        $cart->load([
            'items.variant.product.brand',
            'items.variant.product.taxClass',
            'items.variant.prices' => fn (Relation $q) => $q->active(),
            'items.variant.attributeValues.attribute',
        ]);

        $publicId = Order::generatePublicId();
        $pricing = $this->computeCheckoutPricing->handle($cart, $checkoutData, $publicId);

        $this->validateShippingMethodForOrder->handle($pricing->shippingMethod, $checkoutData->country_code);
        $this->validatePaymentAmountRules($pricing->paymentMethod, $pricing->total);

        $order = $this->persistOrder($cart, $checkoutData, $pricing);

        $this->reserveInventoryForOrder->handle($cart, $order);

        $result = $this->createOrderItems->handle($order, $pricing->orderItemsData);
        $createdItems = $result->items;
        $cartItemMap = $result->map;
        $this->recordDiscountUsage->handle($order, $cart, $createdItems, $pricing->discountResult, $cartItemMap);

        // … redeem a gift card, create the addresses, initialise the payment,
        // record the first status, and mark a fully gift-carded order paid

        $cart->delete();

        return $order;
    });

    event(new OrderPlaced($order));

    return $order;
}

The cart is locked first, so two tabs can't check out the same cart twice. Pricing is computed once, into a single pricing object, and everything after it uses that object rather than recalculating. Stock is reserved rather than deducted: each line gets a reservation that expires after 30 minutes, and an hourly job releases the ones nobody paid for. Stock only actually goes down when the payment is confirmed. The OrderPlaced event fires after the transaction commits, never inside it, so nothing downstream can react to an order that was then rolled back.

Money is integers everywhere: cents in the database, cents over the wire, formatted only at the edge. VAT is extracted with bcmath on decimal strings, with an explicit, configurable rounding mode. And a cart line snapshots its unit price when it is added, while an order line snapshots its name, SKU, attributes, tax rate and tax scheme. An order from March still says what it said in March, even after the catalogue has changed.

The database backs all of this up. Postgres CHECK constraints refuse negative stock and negative reservations, restrict the order, payment, refund and gift-card status columns to the values their enums allow, and require currencies to be three uppercase letters. If a bug ever tries to sell stock the shop doesn't have, the database says no before the shop does.

Order status is a state machine, and the whole machine fits on one screen:

/** @return array<self> */
public function allowedTransitions(): array
{
    return match ($this) {
        self::Pending => [self::Paid, self::Processing, self::Cancelled],
        self::Paid => [self::Processing, self::Shipped, self::Cancelled, self::Refunded],
        self::Processing => [self::Shipped, self::Cancelled],
        self::Shipped => [self::Completed],
        self::Completed => [self::Refunded, self::PartiallyRefunded],
        self::Cancelled => [],
        self::Refunded => [],
        self::PartiallyRefunded => [self::Refunded, self::Cancelled],
    };
}

public function canTransitionTo(self $target): bool
{
    return in_array($target, $this->allowedTransitions(), true);
}

Both the admin screens and the payment code ask the same question before changing a status, and the admin path asks it twice: once before taking the row lock and again after, because the answer can change in between.

The checkout design: contact, delivery with a searchable Omniva locker picker, and payment, with the summary alongside.
The checkout design: contact, delivery with a searchable Omniva locker picker, and payment, with the summary alongside.

On the front end, checkout is one page with numbered sections: contact, an optional business section, delivery and payment. Shipping quotes are re-fetched 400 ms after the last change, and stale requests are cancelled. The form saves itself to the server session as you type (text fields after a second, choices immediately, and one last time with sendBeacon when you leave), so a customer who wanders off to find their VAT number doesn't come back to an empty form.

Payments: trust the database, not the webhook

Payments go through Montonio, a Baltic payment provider that covers bank links for every major Estonian and Baltic bank, cards, BLIK and buy-now-pay-later in one integration. A daily command syncs the available methods per country, including bank logos, and deactivates the ones Montonio no longer offers.

The flow has one rule that everything else follows from: the customer's browser never marks an order as paid. When Montonio sends the customer back, the return controller verifies the token and shows a signed confirmation page, and that is all it does. Only the webhook changes the order, and the webhook is not trusted either:

if ($payload->paymentStatus === PaymentStatus::PAID) {
    $webhookCents = (int) round($payload->grandTotal * 100);
    $currencyOk = mb_strtoupper($payload->currency) === mb_strtoupper($payment->currency);

    if ($webhookCents !== $payment->amount || ! $currencyOk) {
        Log::error('Montonio webhook: amount/currency mismatch', [
            'order_id' => $order->id,
            'payment_id' => $payment->id,
            'expected_cents' => $payment->amount,
            'webhook_cents' => $webhookCents,
            'expected_currency' => $payment->currency,
            'webhook_currency' => $payload->currency,
        ]);
        $this->statusAction->markFailed($order, $payment);

        return;
    }
}

match ($payload->paymentStatus) {
    PaymentStatus::PAID => $this->statusAction->markPaid($order, $payment, OrderStatusContextEnum::MontonioWebhook),
    PaymentStatus::VOIDED, PaymentStatus::ABANDONED => $this->statusAction->markFailed($order, $payment),
    default => null,
};

A "paid" notification only counts if the amount and currency match what the shop stored when the order was placed. The listener locks both the order and the payment before it looks at either, ignores payments that are already paid, and hands the actual change to one class, UpdatePaymentStatus, the only path in the codebase that can make an order paid. It commits the stock reservation, issues any gift cards that were bought, and fires OrderPaid after the commit.

Idempotency is built into the schema, not just the code: payments have a unique index on the order and an idempotency key, and the provider's payment ID is unique too. A webhook delivered twice is a no-op.

And then there is my favourite defensive method in the whole codebase:

public function driver($driver = null): PaymentGateway
{
    $driver = $driver ?: $this->getDefaultDriver();

    try {
        return parent::driver($driver);
    } catch (InvalidArgumentException $e) {
        Log::error('Unknown payment driver requested; refusing to fall back.', [
            'driver' => $driver,
            'configured_default' => $this->getDefaultDriver(),
        ]);

        throw new InvalidArgumentException("Payment driver [{$driver}] is not configured. Refusing to fall back to the manual gateway, which would complete checkout without charging.", $e->getCode(), previous: $e);
    }
}

The payment gateways sit behind a Laravel Manager. The comfortable thing to do with an unknown driver is to fall back to a default, and the default here is the manual gateway used for bank transfers and pay-on-collection. That fallback used to exist. It meant a misconfigured payment method would complete checkout without charging anyone. Now it fails loudly, and the error message says exactly why.

The order confirmation design: a status timeline, delivery and payment details, and an invitation to save the details for next time.
The order confirmation design: a status timeline, delivery and payment details, and an invitation to save the details for next time.

Refunds with three outcomes

Refunds are harder than payments, because the shop starts them and the network can fail halfway. The refund Action works in three steps. It first creates a pending refund under a lock, checking the order total and a per-item cap. Then it calls the gateway, outside any transaction, so no database lock is held while waiting on the network, with an idempotency key of refund:{id}. Finally it records the outcome:

$completed = $gatewayResult === null || $gatewayResult['success'];
$indeterminate = ($gatewayResult['indeterminate'] ?? false) === true;

return DB::transaction(function () use ($order, $payment, $data, $refund, $completed, $indeterminate, $gatewayResult, $actor): Refund {
    $order = Order::query()->lockForUpdate()->findOrFail($order->id);

    $refund->update([
        'status' => match (true) {
            $completed => RefundStatusEnum::Completed,
            $indeterminate => RefundStatusEnum::Pending,
            default => RefundStatusEnum::Failed,
        },
        'meta' => $gatewayResult ? ['gateway_refund' => $gatewayResult] : null,
        'refunded_at' => $completed ? now() : null,
    ]);

    // …
});

The interesting line is the middle one. A gateway timeout is not a failure: the money may or may not have moved. Treating it as failed would let an admin retry and refund the same money twice. Treating it as completed would be a lie. So it stays pending, and a pending refund still counts against how much of the order can be refunded. "I don't know" is modelled as its own state.

The audit that paid for itself

Some of the code above exists because of one night in August when the backend was audited for money bugs. Three real ones turned up, and each was confirmed on the main branch before it was fixed.

The first was the fallback I described: a checkout that could complete without charging. The second was a double refund, fixed by the indeterminate state. The third was the most creative. A gift card is the one item in the shop whose price the customer chooses, within limits. Because of a validation gap, those limits could collapse to "anything", and the discount engine checked coupon thresholds against list prices while checkout charged the customer's chosen amount. The two disagreeing meant a large order could qualify for a big discount while being charged almost nothing.

The fix was not a special case. Gift card amounts are now validated against what the product actually offers, and the discount engine prices lines with exactly the same precedence checkout uses, so the two can no longer disagree. Each fix came with regression tests, and the commit messages are longer than most of the code they changed, which I think is the right way round.

The best part came later. The first double-refund fix didn't work, and its own test hid that. The Montonio driver turned a timeout into a plain failure, so a timed-out refund was marked failed and stopped counting against the cap. The regression test passed because it only mocked a gateway that throws. An adversarial review caught it, and the second fix tests the real driver against a faked transport. A test that mocks the thing that's broken proves very little.

EU VAT without spreadsheets

A shop in Estonia selling to the rest of the EU has to get VAT right, and it is where most small shops cut corners. Tax here is handled by veltix/tax-engine, a package I wrote, wrapped in a service with two strategies: prices that include VAT, and prices that don't. It covers OSS and IOSS, reverse charge for business customers, and export.

Business customers can enter a VAT number at checkout. It is checked in order: EU prefix, format, whether the prefix matches the customer's country, and finally a live VIES lookup. Only a verified number switches the order to reverse charge. An unverified one falls back to consumer treatment rather than failing the checkout. Discounts are allocated across lines before tax, so the VAT on a discounted order is right line by line. Invoices are PDFs with VAT broken down by rate and a reverse-charge notice where it applies. A nightly job snapshots a tax ledger, net of refunds, for the reports.

Search by photo

Most people do not know that their key is an "AX3". They know it has three buttons and a folding blade. So the search box has a camera button.

You upload a photo of your key, and an agent built on laravel/ai describes it as structured data rather than prose: the key type (flip, smart, proximity, standard or fob), the number of buttons and what their icons are, how the buttons are laid out, the shape of the body and the blade. That structured profile is then matched against reference profiles stored on each key-shape category:

public function handle(string $imagePath): array
{
    $profile = $this->describeKeyImage->handle($imagePath);

    $categories = $this->structuredMatch($profile);

    if ($categories->isEmpty()) {
        $categories = $this->embeddingMatch($profile);
    }

    return [
        'categories' => $categories,
        'description' => $profile->summary,
    ];
}

The structured match comes first because it is explainable. Every feature scores points, and the button count weighs most: an exact match is worth 35, one off is still worth 12, and two or more off costs 25. The brand works the same way. The same make is worth 25, a make from the same family 10, because the Volkswagen group's keys turn up on Audi, Škoda and Seat, and an unrelated make costs 25. Only when the structured match finds nothing does it fall back to pgvector similarity, and that fallback has a threshold, so a vague photo returns "not sure" rather than a confident wrong answer. The call to the model is retried with backoff, but only on timeouts, rate limits and server errors, never on a bad request.

The front end, beyond looking good

A few front-end details that took longer than they look.

The cart quantity control is optimistic, and it rolls back properly. Most optimistic UIs sync local state from props with an effect, and get it subtly wrong when a request fails. This one stores the pending value together with the server value it was based on, so it can never outlive the request it belongs to:

const [pending, setPending] = useState<{
    base: number;
    value: number;
} | null>(null);
const localValue =
    pending !== null && pending.base === value ? pending.value : value;
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);

const handleChange = useCallback(
    (newValue: number) => {
        const clamped = Math.max(min, Math.min(max, newValue));
        setPending({ base: value, value: clamped });

        if (debounceRef.current) {
            clearTimeout(debounceRef.current);
        }

        debounceRef.current = setTimeout(() => {
            // `onChange` may reject (stock changed, session expired, a
            // transient 5xx). The `value` prop never changes on a
            // rejected request, so dropping the optimistic entry is what
            // falls the pill back to the last known-good quantity instead
            // of leaving one on screen that the server never accepted.
            Promise.resolve(onChange(clamped)).catch(() => {
                setPending(null);
            });
        }, 300);
    },
    [onChange, min, max, value],
);
The cart design: a free-delivery progress bar, a spare-key offer, and cross-sells from the same key shape.
The cart design: a free-delivery progress bar, a spare-key offer, and cross-sells from the same key shape.

Removing an item from the cart can be undone for five seconds from the toast. The cart drawer opens by itself when the server flashes that something was added, so adding to cart works the same from every page without each page wiring it up.

Search is a command palette on Cmd or Ctrl and K, which leaves the shortcut alone while you are typing in another field. Queries are debounced, stale requests are cancelled, and clicks on results are logged, so the search reports can show what people searched for and whether they found it.

The translations are typed. The PHP language files for English and Estonian are compiled into TypeScript catalogues, bundled up front on purpose so a raw translation key never flashes on first paint, with ICU-style plurals on top of Intl.PluralRules. There is also a hand-written Core Web Vitals reporter. It implements the layout-shift session windows from the spec itself rather than pulling in a package, samples a share of visits, and sends the numbers back with sendBeacon.

The account area design: the latest order with its delivery timeline, the wishlist and the default address.
The account area design: the latest order with its delivery timeline, the wishlist and the default address.

An admin that isn't Filament

The back office is a custom Inertia and React admin, not Filament. There are 67 admin pages, and permissions are derived from route names by one middleware, so a new admin route can't be added without deciding who may use it.

Three parts of it are worth describing.

The menu builder is a drag-and-drop tree with keyboard support and autosave. Its live preview renders the real storefront menu components, scaled down, rather than an imitation of them. Admins never choose between a dropdown and a mega menu, either; the structure decides:

export type MenuPresentation = 'link' | 'dropdown' | 'megamenu';

export function presentationOf(item: ResolvedMenuItem): MenuPresentation {
    if (item.children.length === 0) {
        return 'link';
    }

    return item.children.every((child) => child.kind === 'link')
        ? 'dropdown'
        : 'megamenu';
}

Publishing checks for stale revisions, so one admin can't silently overwrite a newer publish from another.

The page builder composes content pages from 30 block types, rendered by the same components in the editor and on the storefront. It supports real-time co-editing, and the design rule for that was "no locks". Nobody waits for anybody, and nobody sees an HTTP 409. Edits are merged field by field using per-field revisions, and genuine conflicts are shown to the people involved. The text itself is synchronised through Yjs and a small Hocuspocus server, and presence goes over Reverb. Every broadcast happens after the database commit and inside rescue(), so a hiccup on the socket server can never turn a successful save into an error page.

The automation builder is a visual flow editor for marketing emails, with steps for delays, emails, conditions, waiting for an event, goals, A/B splits, generated coupons and outgoing webhooks. It ships with seven flows, among them abandoned carts (a reminder, a two-day wait, then a coupon), win-backs with an A/B-tested coupon, and repeat customers. Every recipient has sending limits per day and per week. The webhook step signs its payloads and refuses internal addresses, and it pins the resolved IP so a DNS change can't slip past the check. Only signed-in customers' carts are ever enrolled in cart reminders.

There is also an SEO module of 63 files: sitemaps, IndexNow, Search Console integration and experiments with meta titles. Structured data is rendered on the server through laravel/head, so crawlers see it without running JavaScript.

Emails and the pages nobody plans for

Order confirmations, status changes, shipping, gift cards and the automation emails share one template system of ten templates, and an admin can edit the copy of each one with placeholders for the order data. Account emails and the newsletter confirmation still use Laravel's notifications. The studio designs all 23 emails, eleven of them as new templates, so none are left as a framework default.

The email gallery in the studio: every message the shop sends, with its trigger, template name and variables.
The email gallery in the studio: every message the shop sends, with its trigger, template name and variables.

And the error pages got the same attention as the home page. The 404 is a small car that drifts around a giant "404" and into a parked car: "End of the road. We couldn't find that page. Good thing the key had a cover on." That one has been in the shop since March. The 500 in the studio is an instrument cluster with its warning lights on: "The engine wouldn't start. Your key is fine – the fault is in our server." That one is still only a design.

The 404 and 500 page designs: "End of the road" and "The engine wouldn't start".
The 404 and 500 page designs: "End of the road" and "The engine wouldn't start".

Keeping it honest

This is the part of the project that took the most discipline, so here are the numbers.

About 5,900 Pest tests

Feature, unit, architecture and browser tests. Every feature test runs in a transaction, and parallel runs each get their own Postgres database, cloned from a migrated template.

100% coverage

Line coverage and type coverage are both required to be exactly 100%. Getting there took about 370 new tests, and deleting 54 policy methods nothing could reach.

About 2,500 Vitest tests

Components, hooks, pages and the architecture rules for the front end, plus Playwright for the flows that need a real browser.

PHPStan at max

Larastan at the highest level with bleeding-edge rules for the app, and a separate, gentler pass over the tests. Every documented exception has a written reason.

Strict formatting

Pint with final classes, strict comparisons, immutable dates and multibyte string functions. oxlint and oxfmt on the front end, with rules traced to audit findings.

Rector, carefully

Rector modernises the code, with three Pest rules off: two loosen assertions (`toBe('')` becomes `toBeEmpty()`, which also passes for null), and one chains unrelated expectations.

A PHPStan baseline is a list of errors you have agreed to ignore for now, and baselines have a habit of only ever growing. This one is guarded by a short script that fails composer test if the baseline gets bigger and lowers the limit whenever it gets smaller:

current=$(grep -c 'identifier:' "$BASELINE")
allowed=$(cat "$COUNTER")

if ((current > allowed)); then
    echo "✗ ${BASELINE} grew to ${current} entries (allowed: ${allowed})."
    echo "  Fix the new errors instead of adding them to the baseline."
    exit 1
fi

if ((current < allowed)); then
    echo "${current}" >"$COUNTER"
    echo "✓ ${BASELINE} shrank: ${allowed} → ${current}. Updated ${COUNTER} — commit it."
else
    echo "✓ ${BASELINE} unchanged (${current} entries)."
fi

It is a ratchet. In August the baseline had 2,288 entries. Today it has 1,298, a 43% cut, and the only direction it can move is down.

The coverage push found more than untested lines. One finding: the permissions package registers a Gate::before hook, so seeding a permission named exactly view would have quietly bypassed every policy in the admin.

The documentation lives in the repository too: 108 files, about half of them audits and most of the rest specs and plans. There is a backend audit, a frontend audit and a coverage audit, and notes on tax, automations and every larger decision. The frontend audit has my favourite line in it. The accessibility linter reported zero problems while 68 icon-only buttons had no accessible name at all. A linter reporting zero only proves what the linter can see, which is why the rule is now also an architecture test.

Working with AI agents without losing the plot

Most of this codebase was written with AI coding agents, and I want to be honest about that: 322 of the 474 commits carry a Claude co-author line. What made that work was not the agents. It was the scaffolding around them.

The repository tells an agent how to behave before it writes a line. There are project guidelines, and a folder of rules that are really lessons learned the hard way. Pint deletes comments, so reasoning goes in commit messages. Rector's dead-code rules once deleted a branch the code needed. Test-only routes must not look like a single lowercase slug, or the storefront's page route swallows them. The StoreMedia type must mirror MediaResource exactly. When an agent learns something new, it records it as a rule in the repository, not in its private memory, so the next agent gets it too.

Larger work follows a spec, then a plan, then a pull request, and there are dozens of specs and plans in the docs folder. But the real safety net is everything in the previous section. An agent can write a controller that touches the DB facade, and unless it's on the allowlist, an architecture test will fail. It can quietly widen the PHPStan baseline, and the ratchet will fail. It can "fix" a failing assertion by weakening it, and Rector is configured not to help. And when the push to 100% coverage deleted code, every deletion was audited by a second agent that hadn't written it.

The lesson I took from it: agents are fast, and they are exactly as careful as your tests make them.

How it got built

The first commit is from February 14th, and it says "WIP". February and March were scaffolding: variants, the first Montonio integration, and a lot of work-in-progress commits. Then git went quiet for almost five months while the work happened on my machine, and in August it came back as a single snapshot commit preserving months of uncommitted work, followed by a careful reconciliation merge. I don't recommend that part.

August was the sprint: 410 commits. The upgrade to Laravel 13, Pest 5 and TypeScript 7. The frontend and backend audits. Translations. The money-bug fixes. 100% coverage. The menu builder. A seven-phase SEO platform. The turn of the month rebuilt the page builder and added real-time collaboration, and September brought the new design and a simplification sweep with one rule: remove bugs, dead code and duplication, but never remove a capability. The latest merged pull request, from this week, localises the buttons in the automation emails and points each one at the right page.

In total: 474 commits over 43 active days, 78 pull requests, 1,025 PHP files and about 72,000 lines in app/, and about 136,000 lines of TypeScript and React.

What isn't done yet

The shop isn't finished, and here is what's still missing.

  • It isn't live yet. autovoti.ee still runs on WooCommerce. The new shop is being prepared to replace it, and the catalogue has already been migrated from it.
  • The new design is still being applied. The studio is ahead of the code: the instrument-cluster 500, the spare-key offer and several of the new email templates exist only as designs so far.
  • Shipping labels are manual. Shipping methods and rates are synced daily from Montonio's shipping API, and Omniva lockers, DPD and pickup work at checkout, but labels and tracking numbers are still entered by hand.
  • Not every check runs on every pull request. The Vitest and Playwright suites, the coverage gates and the baseline ratchet run in composer test locally and in a full CI workflow started by hand, not on every PR.
  • Mutation testing is a spike. It found five real gaps in the discount engine, which were closed. One of them was a continue that could become a break unnoticed, so one promotion failing its conditions would silently abandon every lower-priority promotion. The tooling isn't stable enough to run on every change yet.

Why it was worth it

WooCommerce would have kept selling key covers. What it couldn't do was answer the one question every customer arrives with, and it couldn't give me confidence that a refund would happen once, that a webhook couldn't be spoofed into marking an order paid, or that the VAT on a Finnish business order was right.

The new shop answers the question in two clicks, or with a photo. And the things that must never go wrong are the parts of the code with the most tests, the most locks and the fewest assumptions. For a small shop that is a lot of engineering. For my own shop, it's exactly the amount I wanted.

Contact

Build something that lasts.

Got a Laravel project, a WooCommerce mess or an integration that needs untangling? Tell me what's breaking.

© 2026 Veltix · Kristen LõokeGitHub · Tallinn