Shopware’s sales channels let one catalog power D2C, B2B vet portals, and marketplaces—but the core product entity is still shared. Merchandising often needs different hero copy, badges, disclaimers, and sometimes displayed price context per channel without cloning SKUs (inventory truth would fork).
On a pet-health project we solved this with a sidecar entity keyed by product version + sales channel, wired through subscribers so storefront and admin stayed coherent.
1. Requirements we had to satisfy
- Same SKU, different marketing narrative (consumer vs. professional buyer).
- Retail reference pricing visible in one channel for comparison shopping rules—not the same as the transaction price (tax / contract pricing still flows through normal price rules).
- Admin UX: editors must see “this channel’s overlay” next to the product, not hunt in a generic JSON blob.
- Performance: no N+1 queries on category listing pages.
2. Data model (pattern)
We introduced something equivalent to:
- Entity:
content_for_sales_channel(name illustrative) - Keys:
product_version_id+sales_channel_id(unique composite) - Fields (examples):
headline,bullets(JSON array of strings),disclaimer_htmlretail_priceas optional decimal for display-only contextsactiveflag for soft-disabling a channel row without deleting history
Migration added the column with a safe default (0.00) so existing rows backfilled cleanly.
Why product_version_id?
Shopware versions products; subscribers must resolve the live version id for the context or you attach content to a stale version after edits.
3. Runtime: subscribers and hydration
Subscriber strategy
- Listen to events where product entities are loaded with associations for storefront (category listing, PDP, search).
- For the active sales channel in
SalesChannelContext, look up overlay rows in batch:- Collect candidate
product_version_ids from the page result set. - One
search()withEqualsAnyFilteron version ids +EqualsFilteronsales_channel_id. - Map results into an in-memory array keyed by
versionIdfor O(1) merge in PHP.
- Collect candidate
Merge semantics
- If overlay exists → attach to a non-persisted extension on the loaded DTO / use a Struct passed to Twig via
pageextensions (pick one pattern and stick to it—mixing causes cache bugs). - If missing → fall back to core product fields only.
Admin module
- A tab or card on the product detail screen listing per-channel rows with quick links.
- Validation: disallow empty disclaimer when
activeand channel requires compliance text.
4. Caching and HTTP
If you decorate product output for storefront:
- HTTP cache: mark routes that include channel overlays as uncacheable or use vary by
sw-context-token/ sales channel header—document the decision with DevOps. - Reverse proxy: ensure
Cache-Controlmatches reality; stale PDP copy is a compliance incident.
Indexer / search
- Decide whether channel-specific keywords enter search documents. We kept search global and put channel nuance on PDP only to avoid duplicate SERP noise—product decision, not only tech.
5. Testing checklist
- Switch sales channel in admin preview; confirm overlay swaps.
- Scheduled publishing of product version: ensure subscriber reads current version after publish job.
- API consumers (headless): if they bypass storefront events, expose overlay via Store API extension or dedicated endpoint.
6. Resume framing
Describe it as custom entity design, batch DAL queries, sales-channel-aware subscribers, and explicit cache policy—that is senior Shopware work, not “we added some fields.”