← Blog

E-commerce

Shopware 6 API gotchas: a brewery rebuild cheatsheet

Friday afternoon, an Eindhoven brewery's product-filter page returns 500s on every variant without a cover image. The PHP SDK said the field was optional. The store API disagrees.

Jacob Molkenboer· Founder · A Brand New Company· 11 Jun 2026· 10 min
Small twine-tied parcel beside a tipped brass scale, green postcard underneath, wax seal, ivory paper table, side light.

Friday afternoon in Eindhoven. The brewery's marketing lead pings us: the product-discovery page returns 500s for half the beer variants, the new gift-box landing flickers between "in stock" and "out of stock" on refresh, and the staging API has been throwing VIOLATION::FORMAT_NOT_SUPPORTED for two days. Black Friday is nine weeks out. They wanted faceted filters, fast search, and a clean storefront API for the new headless front end. They got a stack trace.

We had moved them onto Shopware 6.6 the month before, and most of the visible bugs traced back to one root cause: the official PHP SDK serialises a long list of product fields as nullable, while the store API treats those same fields as required at render time. The ORM lets you write nulls. The customer-facing API refuses to read them. Add a handful of quirks around variants, context tokens, and aggregation shapes, and you end up with the cheatsheet below.

Seventeen items, ranked by which ones the SDK lies about hardest. While the rest of the internet was reading about an AI agent that ran amok inside a Fedora install, the brewery's checkout was quietly throwing 500s on cover-image rendering. Both stories have the same moral: schemas matter, and "nullable" is a contract, not a suggestion.

Why the PHP SDK and the store API disagree

Shopware 6 ships three API surfaces. The admin API talks OAuth and gives you direct access to the Data Abstraction Layer. The store API is token-based, customer-scoped, and runs every payload through the rendering pipeline. A legacy sales-channel API still ships for backwards compatibility, and you will spend a morning ripping it out of one or two plugins that nobody touched since 2022.

The PHP SDK in shopware/core models entities for the admin path. That means nullable PHP types, optional associations, and a lot of ?string in the Doctrine annotations. The store API, however, expects a resolved cover image, a calculated price, a non-null product name, and a navigation category. If your sync job leaves any of those null, the admin API accepts it, the database stores it, and the storefront returns 500s. Or, worse, it returns an entity with empty strings and zero-euro prices, which is the kind of bug you only catch when a customer screenshots it.

The seven nullable-but-required offenders, ranked

1. cover on the product entity. The DAL marks coverId as nullable. The store API resolves cover via the product-media junction and the entity hydrator. Set it to null on a visible product and the listing endpoint will fall over with a FormatNotSupportedException the moment the thumbnail service tries to size a missing media file. Fix at the source by enforcing coverId in your import schema, not by patching the thumbnail service.

// admin API write that the SDK happily accepts and the storefront cannot render
$repository->upsert([[
    'id' => $productId,
    'productNumber' => 'BR-IPA-330-6PK',
    'name' => 'Tripel six-pack',
    'stock' => 120,
    'taxId' => $taxId,
    'price' => [['currencyId' => $eur, 'gross' => 17.95, 'net' => 14.83, 'linked' => true]],
    // missing: coverId, manufacturerId, visibilities, categories
]], $context);

2. price as an empty array. The SDK accepts price: [] on upsert. The store API throws on the calculated-price step because the rounding strategy needs at least one currency match. If you cannot guarantee a price per currency at write time, gate the visibility flag instead of letting an empty array reach production.

3. name on variants. Variants inherit their name from the parent. If the parent is renamed and the variant has a stale, non-null override, the store API serves the override. If the variant override is null, it inherits. So far so good. The bug appears when a migration script sets the variant name to an empty string instead of null. The SDK treats empty string as valid; the store API renders an empty product card.

4. visibilities for the sales channel. A product is not visible to a sales channel unless a row exists in product_visibility for that channel. The SDK does not enforce this on write. The store API silently filters the product out of every listing and search response. The brewery had 280 SKUs in the database and 41 visible on the storefront for three days before anyone noticed.

5. categories association. Faceted navigation joins through product_category. A product without a category will return from a direct /store-api/product/{id} lookup but will never appear in /store-api/product-listing/{categoryId}. The brewery's gift boxes were not in any category because the import treated "Gift" as a tag, not a navigation category. Two-line fix in the import script. Six hours to find.

6. manufacturerId. Nullable in the DAL, required by the manufacturer filter aggregation if you want it to render. If half your products have a manufacturer and half do not, the aggregation returns a "no manufacturer" bucket with a null key, and the storefront components throw on the missing label. Either backfill manufacturers for every product or strip the aggregation from the criteria.

7. customFields shape. The DAL stores customFields as a JSON column. The SDK types it as ?array. The storefront components read specific keys. If a typed component expects custom_brewery_abv and the field is null (not an empty object, null), the component renders the literal string "null" inside the product card. Default to {}, never null.

Warning

If your import job writes products through the admin API and you check the storefront only after a full reindex, you can ship eight of these seven bugs at once and only see them when the first customer hits the filter sidebar. Wire a smoke test that fetches three random product IDs through the store API after every import batch.

Context, tokens, and the sw-context-token foot-gun

The store API authenticates with an access key in the sw-access-key header. State (cart, customer session, language, currency) is carried in sw-context-token. The token rotates on login, on currency switch, and sometimes on cart mutation. Naive clients cache the first token they receive and send it forever. The store API then returns a context that no longer matches what the user did three clicks ago, and the cart appears empty on checkout.

// always read the response header back into the client
const res = await fetch(`${baseUrl}/store-api/cart/line-item`, {
  method: 'POST',
  headers: {
    'sw-access-key': accessKey,
    'sw-context-token': ctx.token,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ items: [{ referencedId: productId, quantity: 1, type: 'product' }] }),
});

const next = res.headers.get('sw-context-token');
if (next && next !== ctx.token) ctx.token = next; // do not skip this

total-count-mode and the silent pagination bug

The total-count-mode query parameter has three values: 0 returns no total, 1 returns an exact total, 2 returns a "next page exists" boolean. The default flipped between Shopware 6.4 and 6.5 on certain endpoints. If your front end paginates by computing Math.ceil(total / limit) and the API returns total: 0 because the mode is 0, you will render a single page even when the catalogue has 4,000 SKUs. Pass the mode explicitly on every request.

Variants, parentId, and the cover-image inheritance trap

Variants inherit almost everything from the parent product. Almost. The cover image inherits only if the variant has no productMedia rows of its own. The brewery wanted variant-specific bottle photography for the 75cl format and not for the 33cl. The importer attached a 75cl photo to every variant and left the 33cl variants with the parent photo. The store API correctly inherited for 33cl. It also incorrectly served the 75cl photo as cover for variants where the bottle photo was meant to be a gallery shot, because coverId was not explicitly set and the hydrator picked the first associated media row by sort order.

The fix: set coverId explicitly on every variant that has its own media. Never rely on the implicit first-media-row rule. The Shopware Data Abstraction Layer docs describe the inheritance graph in detail; print the relevant page and put it on the wall.

Aggregations look the same. They are not.

Both APIs accept a Criteria object with an aggregations key. The admin API returns aggregations nested inside the entity-search result. The store API returns aggregations as a top-level aggregations object next to elements. If you wrote a shared TypeScript type for the two responses, you will discover that one of your filter sidebars is reading an undefined path and silently rendering zero buckets. Split the types. They are not the same shape.

The remaining ten, in one breath

  • 8. productNumber uniqueness extends to soft-deleted rows. Reusing a number from a deleted SKU throws on upsert.
  • 9. seoUrls need the SEO URL indexer to run before the storefront serves the pretty URL. New products show the technical URL until the queue worker catches up.
  • 10. availableStock is computed from stock minus open orders. Never write to it directly. The DAL will let you. The next stock recalculation will overwrite you.
  • 11. price.linked: true recalculates gross from net whenever the tax rate changes. Set linked: false if you import gross-first prices.
  • 12. The sw-access-key is bound to a sales channel. One key per channel. Mixing them silently filters the catalogue to the wrong channel.
  • 13. Criteria::addAssociation('cover.media') works in the admin SDK but is a no-op in the store API where cover media is preloaded. Adding it does no harm, but reading code that mixes the two is painful.
  • 14. releaseDate hides a product from listings until the date passes, even when active: true. The brewery's seasonal beers were invisible because the importer set the release date six weeks ahead by mistake.
  • 15. Product properties (propertyIds) drive filter facets. A product without properties never appears in property-based filters. Hops, ABV, IBU, style. Backfill them or the filter sidebar is dead weight.
  • 16. The store API rate limit is per-token, not per-IP. A buggy retry loop on one tab will not throttle other customers, but it will lock the buggy tab out for the cool-down window.
  • 17. Guest customer sessions expire after a configurable interval. The default is shorter than most teams expect. Cart-abandonment metrics will look bad until you tune it.
Takeaway

If a Shopware 6 field is nullable in the PHP SDK but required by your storefront's render path, treat the SDK's nullability as a documentation bug and enforce the constraint at your import layer. The store API will not tell you nicely.

What we shipped to keep the brewery sane

The fix was not glamorous. We wrote a Symfony console command that walks every product in the catalogue, runs each of the seventeen checks, and emits a CSV with one row per SKU and one column per gotcha. Green for pass, red hex for fail, with the offending field name in the cell. The marketing lead now runs it before any campaign push. The same command runs in CI against the staging import, so the next time a feed change silently nulls out a cover, the deploy fails before the brewery's customers see a 500.

The headless storefront went live three weeks before Black Friday. The product-discovery page now renders in 180ms p95 on a single VPS. The marketing lead has not pinged us about a 500 in six weeks.

When we ran the same audit on another legacy migration last year, the pattern held: most production storefront bugs are not bugs in Shopware. They are mismatches between a permissive write API and a strict read API, and the fix lives in the import job, not the storefront. Run the audit before the storefront ships, not after.

Five-minute thing to do today: grep your import code for any field assignment of null on a Shopware product, and ask whether the storefront can render it. If you cannot answer yes in one sentence, the answer is no.

Key takeaway

In Shopware 6, treat the PHP SDK's nullability as a documentation bug. The store API enforces what your import layer leaves out.

FAQ

Do these gotchas apply to Shopware 6.5 as well?

Most do. The total-count-mode default and a handful of SDK nullability annotations shifted between 6.4 and 6.6, but the seven nullable-but-required fields behave the same way on every minor release we have shipped against.

Can the store API errors be caught at admin write time?

Not by the admin API or the SDK on their own. You need a validation pass inside your import job, or a CI smoke test that fetches a sample of products through the store API after every import batch.

Is the seventeen-check audit command something you can share?

The internals are brewery-specific, but the checks are listed in the post. Translating them into a Symfony console command takes one focused afternoon and pays for itself the first time it catches a null cover.

Why use the store API instead of the admin API for the storefront?

The store API is customer-scoped, token-aware, and runs the rendering pipeline. Using the admin API from a public storefront leaks data, skips price calculation, and bypasses the visibility filter.

e-commercephpintegrationsarchitecturemigrationcase study

Building something?

Start a project