← work

2026 — now · work

Search, stock and checkout work for a supplements web shop

Developer, Morgen is Nu

Taking over a Shopware 6 shop that sells health supplements in four languages: a search built on the Luigi’s Box APIs, a stock feed that stopped fighting Shopware, and the checkout bugs in between.

Shopware 6 · PHP 8 · Symfony · Twig · MySQL · Luigi's Box · Mollie

Morgen is Nu is a Dutch shop for supplements: magnesium, zinc, vitamin D, liposomal this and that, from brands like Vimergy and Cymbiotika and under its own label. It runs on Shopware 6.5 with a storefront in Dutch, English, German and French, and almost everything that makes it look and behave like itself lives in one custom plugin, WAUWFoundation, that an agency built and left. I took the shop over in March 2026 as its only developer. The work since has been the kind a live shop needs: a stock feed that was lying to customers, two payment bugs, a parcel-point picker, and then the big one, replacing the product search.

Stock that Shopware kept recounting

The warehouse runs on Exact, and a middleware called Xcore pushes stock into Shopware. That figure already has the open orders subtracted. Shopware then subtracted them again: it keeps its own available_stock next to stock and lowers it on every order it sees. A product with one on the shelf and one unshipped order showed no buy button, and the shop owner could see the box on the shelf.

The previous fix forced available_stock back to stock on every product load. That is a database write per page view, it raced with Shopware’s own updater, and it never got the available flag right for variants and bundles. I turned Shopware’s stock updater off entirely, a compiler pass that drops its event subscriptions plus a no-op decorator for the one place the product indexer calls it directly, and moved the mirroring to write time: when Xcore or an admin writes stock, the same write command also carries available_stock, and the available flag is recalculated with the formula core uses. One command, run once after deploy, brought the existing rows in line. Exact is the truth and Shopware no longer has an opinion.

Two things Mollie did

A Mollie plugin update started adding the children of a product bundle to the payment request without removing the bundle parent, so the line items added up to twice the order and Mollie refused it. Anyone with a bundle in the cart could not pay. A decorator on the line item builder drops the children again, and a console command shows what would be sent for a given order number, so the next time totals disagree it takes a minute to see why.

The second was quieter. When a payment expired at Mollie but the order had meanwhile been paid another way, the webhook set the transaction back to cancelled or failed. A decorator on the transition service now refuses to move a transaction out of paid, authorised or refunded, and logs that it did.

What the search had to keep

The search was Shopware’s MySQL search; Elasticsearch was never switched on. Typing an ingredient found nothing unless it was in the title, a variant SKU found nothing, and a search for shampoo listed the same product four times, once per variant. The owner chose Luigi’s Box, a hosted search and discovery service, and asked for it on the acceptance environment first.

Luigi’s Box would rather you drop its JavaScript widgets into the page. I did not, because the shop’s search page is heavily its own: a filter sidebar, price-per-day labels, quick add to cart, USP bars injected into the product grid, and four languages of it. Everything goes through their REST APIs instead and the rendering stays ours. On the results page Luigi’s Box only decides ranking and facets. The hits come back as product numbers, Shopware loads the real product entities through the sales-channel repository in that order, and the page is rendered by the templates that were already there, so calculated prices, promotions, labels, wishlist buttons and the SEO of the page did not change at all.

Where that sits matters. Shopware’s search route is a stack of decorators. Mine is at priority -1500: inside the one that resolves paging and sorting from the URL, so those arrive prepared and the filter sidebar is post-processed normally on the way out, and outside the cache, because the results are personalised per visitor and a cached page would show one shopper another shopper’s ranking. Any error or timeout, two seconds to connect and four to answer, falls through to the MySQL search underneath. A switch in the plugin config turns the whole thing off without a deploy. That switch was the go-live and is the rollback.

The search route as a sandwich. Luigi’s Box ranks, Shopware renders, and the old search is still underneath for the day the API does not answer.

One product, four indexes

Luigi’s Box has no idea of language. A tracker is one catalogue in one language, so the shop has four, and a product is synced once and fanned out to each with that language’s title, description and URL. The sync loads products through a per-language sales-channel context, which is what makes the translations, the calculated prices and the shop’s per-channel overrides of manufacturer and images come out exactly as a visitor would see them.

Only parent products are indexed, which cured the shampoo problem, and the children’s SKUs and EANs ride along on the parent so a search for a variant code still lands. Supplements get searched by what is in them, so the ingredients text is indexed, and the nutritional table is parsed into rows like Zink 25 mg. That parsing needed care: Luigi’s tokeniser splits on whitespace, so (2000 IE) and 20mcg never matched 2000 or 20 mg until the mapper spaced them out. A hidden margin field, purchase price against selling price, lets the owner boost profitable products in Luigi’s own rules. The boost flag itself goes only to products marked as topsellers; Luigi’s support pointed out on the first sync that boosting everything boosts nothing.

Two constraints on their side shaped the tooling. A field’s type is fixed by the first value ever pushed and cannot be changed by API afterwards, so every full sync has a dry run that prints the payload, and the go-live checklist says to read it. And there is no truncate: a full sync is mark and sweep, every object carries a generation token and a commit deletes whatever does not, in batches of 100, with a guard that refuses to commit an empty generation.

Changes reach the index through an outbox. Product writes, including the stock write-backs from Xcore, and placed orders mark rows in a sync table; a message on the queue drains it, and a scheduled task sweeps every five minutes in case the queue is dead. Luigi’s Box is never called in the write path. A product switched off, or given the new Exclude from search checkbox for discontinued items whose pages stay up for Google, becomes a delete on the next pass.

The search dropdown on a desktop after typing magnesium: a column of query, category and brand suggestions on the left, one large top product in the middle, a grid of six products with prices on the right and a button reading Toon alle zoekresultaten.
The dropdown: suggestions, one top product, a grid, all ours.
The same dropdown with an empty search box: popular searches as chips, a top product and five best-selling products.
Focus with nothing typed shows trending searches and top items.
The search results page for magnesium: 136 products found, a sidebar of brand and active-ingredient filters, sorting by relevance, and three product cards with prices and add-to-cart buttons.
Results ranked by Luigi’s Box, rendered by the shop’s own listing.
The search dropdown on a phone: seven magnesium products as a tight list with thumbnails and prices, and a sticky Toon alle zoekresultaten button at the bottom.
The same dropdown on a phone, as a list.

4 images — swipe or scroll sideways

The dropdown

The header autocomplete is client side, because the endpoint is public and the browser reaching it directly is faster than a round trip through the shop. It starts at one character with a 300 millisecond debounce and draws a hero layout: query chips, categories and brands on the left, one large first product, a grid, an all-results button. On a phone the same render collapses into a list with the button stuck to the bottom of the scroll area. One thing I learned by measuring rather than reading: the mix of types you ask for, seven items and six categories and so on, changes which items come back and in what order, not just the layout, so the dropdown and the results page have to be kept in step by hand.

With nothing typed, focusing the box shows trending queries and top items. Categories and brands deep-link to their pages. A redirect the owner configures in the Luigi’s Box app, say from a brand name to the brand page, is honoured by the results route with a 302, but only on the page itself, never on the ajax fragments that refresh the sidebar.

Where it went wrong

The first mistake was mine. Luigi’s Box lets you strip a category level from the indexed data so it does not crowd the suggestions, and I stripped Gezondheidsdoelen, health goals. Searches for energie and slaap then stopped finding anything, because those words had only existed in the category names. The category configuration now has three fields with different meanings, and the one the owner is meant to use hides a category from the dropdown while keeping it fully indexed.

The second took four rounds with their team after go-live. Every event the shop sends, a page view, a click, an add to cart, a transaction, has to name a product by exactly the identity in the catalogue, and the catalogue identity is the product’s relative URL. The events took theirs from the page address and from product links, which differed through www and non-www, through the acceptance hostname, through the /en subfolder, through a JSON encoder escaping slashes, and finally because listings show variants and variants are not in the index. Each round fixed the cases that round’s screenshots showed. The fix that held was to stop guessing: a Twig function reads the identity that was actually synced for a product’s parent in the current language, from the sync table, and puts it on the page as a data attribute; the JavaScript reports that or nothing. Only products that are in the catalogue produce events, and a variant always reports its parent.

A third thing I built and threw away on purpose. Brand-plus-category queries like Vimergy B12 return the brand but not the category, because a suggestion matches the whole query. I wrote a client-side shim that split the query and searched the remainder, then reverted it: splitting queries is search logic, it belongs with Luigi’s Box, and doing it in the browser would have distorted their query analytics.

Consent, history and recommendations

Personalisation needs a client id, which is a cookie. The shop’s consent banner is Cookiebot injected through Tag Manager, so a bridge script maps its marketing category onto one cookie of ours: without it no collector loads, no events leave the browser, and the search route sends no identity, so search still works, just not personalised. To give ranking something to start from, a one-off command uploaded 52 weeks of orders, variants mapped to parents, cancelled orders left out. A transaction event on the order finish page carries both the anonymous id and the customer id, which is where Luigi’s Box stitches a browsing profile to an order history.

Recommendations came last: alternatives on a product page, things that go with the basket in the cart, the drawer and the address step. Product pages are HTTP cached, so a personalised slider cannot be rendered into them; a placeholder is, and the browser fetches a fragment from an uncached route that asks Luigi’s Box and hydrates real product boxes. An empty model name for a placement falls back to the upselling plugin that was there before, and a cookie set by a query parameter let the owner preview the sliders on production before the switch was thrown.

The smaller pieces

Promotion codes could only be entered on the cart page. They can now be typed into the cart drawer and removed there, and every checkout step shows the same sum: products first, then each discount as its own line, then shipping, then the total. QLS parcel points became a shipping method, so a Dutch customer picks a pickup point at the address step and the choice travels on the order to the warehouse. Three bought plugins whose store licences had lapsed were vendored as Composer path packages so the shop kept its bundles and back-in-stock notifications. And the shop deploys from GitHub Actions on a self-hosted runner to the Hypernode, so a push to acceptation is the test environment and a merge to main is production.

The search went live in all four languages on 6 August 2026 with 1,183 parent products in each index. A search for magnesium returns 136 of them.

The pieces and how they talk. Stock enters on the left and is written once. Search leaves through the outbox and comes back as ranked ids that Shopware turns into pages.