Search by

tenbruggencate / multibrand-lite

guy@tenbruggencatedevelopment.nl

Shopware 6 multi-brand layer: serve up to 5 brand domains from one shop, hostname-resolved brand tokens injected into the page head.

Package info

bitbucket.org/Bruggencate/sw-plugin-multibrand-lite

Homepage

Issues

Documentation

Type:shopware-platform-plugin

pkg:composer/tenbruggencate/multibrand-lite

Statistics

Installs: 170

Dependents: 0

Suggesters: 0


README

ten Bruggencate Development

Multi-Brand Lite

Multi-Brand Lite

Hostname-driven brand layer for Shopware 6. One Shopware instance serves multiple brand domains; each request resolves to a brandKey in Twig and a set of --brand-* CSS custom properties in the page head.

License: MIT ยท Shopware: 6.7.x ยท PHP: 8.2+

๐Ÿ‡ฌ๐Ÿ‡ง English ยท ๐Ÿ‡ณ๐Ÿ‡ฑ Nederlands ยท ๐Ÿ‡ฉ๐Ÿ‡ช Deutsch

Lite tier โ€” and what Pro adds

Multi-Brand Lite (this package, tenbruggencate/multibrand-lite) is the free tier of the Multi-Brand family. It handles the foundation: hostname-based brand resolution, per-brand theme variables (CSS custom properties), Twig helpers, and a five-slot brand config screen. If you just need "serve brand A from brand-a.tld and brand B from brand-b.tld with their own colours, favicons and structured-data logo", Lite is the whole answer. Fonts and the storefront header logo come from your theme: Lite gives it the brand key (data-brand) and colour tokens to switch on.

Multi-Brand Pro (tenbruggencate/multibrand-pro) is released and installs side by side with Lite โ€” it requires Lite and extends it rather than replacing it. Pro is not published on Packagist: it is delivered directly today, and a Shopware Store listing is in preparation. What it adds:

  • Per brand its own subject, sender name, HTML body and plain-text body for every transactional email type
  • Applied automatically at send time on every path (Flow Builder or direct); the brand is derived from the sales channel's domain
  • Override only what you want: empty fields fall back to the Shopware default; a toggle to pre-stage overrides before arming them
  • Fail-safe: on any error the email goes out unchanged and the error is logged; Shopware's mail templates are never touched
  • Managed under Settings โ†’ Multi-Brand Pro with list and form, including hints for brand keys and template types; EN/NL/DE
  • Export all data button: JSON with overrides and configuration, also via API; keep or remove data on uninstall

โ†’ What's in Multi-Brand Pro

Lite alone is production-ready for the "shared catalogue, separate visual identity" use case and stays free.

Screenshots

Storefront of the test shop on host localhost, rendered as brand A with a green --brand-primary on buttons and links.

Brand A โ€” host localhost, green tokens

The same storefront on host 127.0.0.1, rendered as brand B with a red --brand-primary on the same buttons and links.

Brand B โ€” host 127.0.0.1, same shop, red tokens โ€” buttons, links and the header line use var(--brand-primary) through a theme rule like the SCSS example below

Multi-Brand Lite configuration page in the Shopware admin: getting-started card stating the settings are global, and the Brand 1 card with key, hostname pattern, name and colours.

Admin configuration โ€” global settings, one card per brand slot

What it does

Serving two or three brand domains from a single Shopware install normally forces you to either (a) fork templates per brand or (b) duplicate the whole store as separate sales channels. Both paths are expensive to maintain. This plugin takes the middle path:

  • One host โ†’ one brandKey โ€” configure which hostnames resolve to which brand in the admin (substring match, not case-sensitive)
  • Per-brand CSS tokens โ€” matcha green for one brand, Darjeeling amber for the other, or whatever you want, emitted as var(--brand-primary) etc. so the same SCSS compiles once and renders differently per host
  • Twig helpers โ€” brand_key(), brand_config(field), is_brand(key) and brand_css_properties(); they also work in mail templates (the mail's sales channel decides the brand)

No per-brand plugins, no duplicate theme compilation, no forked templates. Just a thin resolution layer and a token system.

Install

Multi-Brand Lite is free and distributable via Composer / Packagist or via the Shopware Store.

composer require tenbruggencate/multibrand-lite
bin/console plugin:refresh
bin/console plugin:install --activate TenBruggencateMultiBrand
bin/console cache:clear

The plugin class name stays TenBruggencateMultiBrand (grandfathered from v1.x โ€” only the composer package name changed in v2.0.0). If you're upgrading from v1.x see the v2.0.0 changelog entry for the one-line composer manifest update.

Configuration

Configured from Settings โ†’ System โ†’ Plugins โ†’ Multi-Brand Lite โ†’ Config. Brand definitions are read from the global config namespace and are deliberately not overridable per sales channel โ€” a brand is hostname-derived, so two sales channels on the same host share one brand by definition (see the design-decision note in BrandResolver). Since v2.3.0 the getting-started card says so on the screen itself; values saved against a specific sales channel are ignored at render time. The screen has a getting-started card, one card per brand slot โ€” Brand 1 (default) through Brand 5 โ€” and a JSON backup/restore card. Each slot N (1โ€“5) holds:

FieldPurpose
brandNKeyMachine key for the brand, e.g. north โ€” returned by brand_key() and rendered as data-brand
brandNHostnamePatternHostname fragment that resolves to this brand (substring match, not case-sensitive, first match wins; saved in lowercase)
brandNName, brandNEmailDisplay name and contact address for templates and the Organization / WebSite JSON-LD. Left empty, they are omitted from the JSON-LD (no placeholder)
brandNLogoPath, brandNFaviconBasePathLogo (used in the structured data) and favicon folder. Must start with / (site root, e.g. /media/acme/logo.png) or https://; anything else is ignored and logged
brandNLegalNameRegistered company name, emitted as schema.org legalName when it differs from the display name
brandNStreetAddress, brandNPostalCode, brandNCity, brandNCountryThe PostalAddress block. brandNCountry must be an ISO 3166-1 alpha-2 code (two letters, normalised to upper case); anything else is dropped with a log warning
brandNTelephone, brandNVatIdschema.org telephone and vatID
brandNSameAsTextarea, one absolute http(s) URL per line โ€” the brand's own social profiles and its Wikidata / Wikipedia entry. Lines that are not http(s) URLs are skipped, the rest are kept
brandNPrimary, brandNSecondary, brandNSurface, brandNAccent1โ€“3, brandNTextStrong, brandNTextSoftThe eight colour tokens, emitted as --brand-* CSS custom properties

The eight organisation fields (brandNLegalName through brandNSameAs) are optional and feed the per-brand Organization JSON-LD in meta.html.twig. Every one of them is omitted from the emitted data when empty, so a shop that never fills them in gets the basic organisation data โ€” plus the logo, emitted as an ImageObject. Logo fallback rule: the brand's own brandNLogoPath wins whenever it is set (a /โ€ฆ path is made absolute against the request's scheme + host, an https:// URL is passed through untouched); the shared theme logo (theme_config('sw-logo-desktop')) is only used when the resolved brand has no logo path. On a multi-brand shop the theme logo is the same image for every brand, which is exactly the problem this rule solves.

Colour values must be 6-digit hex; anything else is replaced by a neutral default instead of being emitted into the page. Brand 1 is the fallback โ€” when no hostname pattern matches (admin preview URLs, direct IP access), brand 1 renders and a warning is logged once per host (not on a single-brand shop without patterns). Overlapping patterns between slots are logged.

Usage

In any Twig template:

<body data-brand="{{ brand_key() }}">
  <h1>Welcome to {{ brand_config('name') }}</h1>
  {% if is_brand('north') %}<p>Only on the north brand.</p>{% endif %}
</body>

brand_config() reads one field of the resolved brand: key, name, email, primary, secondary, surface, accent1โ€“accent3, textStrong, textSoft, logoPath, faviconBasePath, legalName, streetAddress, postalCode, city, country, telephone, vatId and sameAs (a list of validated URLs, every other field a string). brand_css_properties() returns the colour tokens as a --brand-* map; the routing config (hostnamePattern) is deliberately not exposed to templates.

Mails, CLI and workers

The Twig functions also run in mail templates, which are rendered outside a storefront request (from the admin, a message worker or the CLI). There the brand comes from the mail's sales channel: Shopware passes salesChannel / salesChannelId to every mail template, and the plugin matches that sales channel's domain URLs against the hostname patterns. On a storefront request for the same sales channel the visitor's host still wins. Without a request and without a sales channel the plugin falls back (request host or brand 1) and logs one warning per process, so a wrong brand is never silent.

For developers: the public API

TenBruggencateMultiBrand\Service\BrandResolver is a public service. Inject it to resolve brands in your own code (Multi-Brand Pro does):

$resolver->matchHost('Shop.Acme.com');                     // ?Brand โ€” no fallback
$resolver->resolve($request);                              // Brand โ€” brand 1 fallback
$resolver->resolveForSalesChannel($salesChannelId, $host); // Brand โ€” host, then the channel's domains
$resolver->resolveForContext($salesChannelContext);        // Brand โ€” also accepts a Context
$resolver->resolveCurrent($salesChannelId);                // Brand โ€” what the Twig functions use

meta.html.twig automatically emits the <style> block with --brand-* tokens; your SCSS reads them with graceful fallbacks:

.button--primary {
  background: var(--brand-primary, #5B7F3A); // green-tea fallback
}

Standards

  • Performance โ€” the brand config is read once per request (memoised, reset between requests on workers); matching is a loop over at most five patterns. Nothing is cached per host. Token block adds ~400 bytes to <head>; no runtime JS.
  • SEO โ€” no impact on URL structure, canonicals, or sitemaps. Each brand's sales channel retains its own SEO settings.
  • WCAG โ€” CSS custom properties are a progressive enhancement; colour-contrast guarantees must be satisfied per-brand by the theme consuming the tokens (this plugin doesn't enforce them). Axe audit on a storefront page with the resolver active (0 violations on the plugin's own injection; theme-owned violations out of scope): docs/ACCESSIBILITY.md.
  • GDPR โ€” stateless. No cookies, no tracking, no data stored per visitor. Full data-flow + subject-rights documentation in GDPR.md.
  • Security โ€” host resolution uses Request::getHost() (trusted-proxy aware); no user-controllable input touches the brand lookup.
  • Uninstall โ€” plugin:uninstall --keep-user-data preserves all brand config; plugin:uninstall without the flag drops every TenBruggencateMultiBrand.config.* row so the destructive path leaves no trace. No owned tables.

Production-readiness checklist

A deliberately short list of things to verify before enabling the plugin on a customer-facing storefront. Not legal cover โ€” the MIT license already disclaims warranty โ€” but practical guidance an experienced operator would want anyway.

  • [ ] Configure trusted_proxies if you sit behind a CDN / load balancer. Request::getHost() reads the Host header (or X-Forwarded-Host when proxies are trusted). Without trusted proxies set, the wrong hostname can resolve and visitors see the default brand instead of yours.
  • [ ] Make brand 1 your default brand. There is no separate default setting: brand 1 is the fallback for admin preview URLs and unmatched hostnames. Watch the log for "matches no brand hostname pattern" warnings after launch.
  • [ ] Let your CDN / Varnish / reverse-proxy cache vary on Host. The page HTML differs per brand host (tokens, data-brand, JSON-LD). A cache that keys only on the path serves brand A's page on brand B's domain. Shopware's own HTTP cache keys on the full URL including the host; check any cache in front of it.
  • [ ] Test each brand domain on staging. Hit curl -I https://brand-a.staging.tld/ and grep the response for data-brand="brand-a" and the brand-specific --brand-primary token; if either is missing, host resolution isn't matching.
  • [ ] Don't try to use hreflang between brand hostnames โ€” see the GEO note below for why. If your "brands" are actually locale-variants, use Shopware's SalesChannelDomain per-language config instead and uninstall this plugin.
  • [ ] Theme contrast is per-brand, not per-plugin. This plugin injects tokens; your theme has to satisfy WCAG colour-contrast for every brand combination. Run an axe-core audit on each brand domain before launch.

Compatibility

Core platform

ShopwarePHPStatus
6.7.x โ€” tested with 6.7.158.2 or newerStable
6.6.xโ€”Not supported
6.5.x and earlierโ€”Not supported

Database

EngineVersionNotes
MySQL8.0+Primary target; JSON functions used for config-row manipulation in migrations
MariaDB10.11+Tested end-to-end; earlier versions lack some JSON operator support

Browsers (storefront)

Evergreen browsers only โ€” the two most recent stable releases of each:

BrowserDesktopMobile
Chrome / Chromiumโœ…โœ…
Firefoxโœ…โœ…
Safariโœ… (macOS)โœ… (iOS 16+)
Edgeโœ…โ€”

Internet Explorer and legacy Edge are not supported. The plugin emits no runtime JS, so graceful degradation on older browsers usually still renders content, just without progressive enhancements.

Admin browsers

Same evergreen matrix โ€” the Shopware admin is Vue-based and has its own compatibility baseline that this plugin doesn't extend or narrow.

Development

ToolVersionScope
PHPโ‰ฅ 8.2Runtime + test suite
Composer2.xDependency management
Node.jsโ‰ฅ 18Only needed if you edit SCSS and re-run the theme compile

Accessibility

WCAG 2.2 level A + AA โ€” see docs/ACCESSIBILITY.md for axe-core audit output and per-page violations.

What we test before each release

  • Full PHPUnit unit suite on PHP 8.2 or newer (source-inspection tests don't need a kernel)
  • PHPStan level 8 + PHP-CS-Fixer (@PSR12 + @Symfony)
  • Composer validate
  • Live-DB smoke tests (plugin install โ†’ activate โ†’ brand resolves per host โ†’ uninstall cycle) against Shopware 6.7.15

Uninstall note

If you also install a theme plugin or another plugin that declares a composer dependency on tenbruggencate/multibrand-lite (or, for legacy installs, the pre-v2.0.0 name tenbruggencate/multibrand), Shopware's plugin manager will refuse to uninstall MultiBrand while that dependant is still active โ€” you'll see PluginHasActiveDependantsException. This is correct Shopware behaviour, not a MultiBrand bug: uninstalling MultiBrand first would break the dependant plugin at runtime.

To uninstall MultiBrand cleanly:

  1. Uninstall the dependant plugin(s) first, or remove the require entry from their composer.json and re-run composer update.
  2. Then bin/console plugin:uninstall TenBruggencateMultiBrand.

For vanilla MultiBrand installs (no composer-dependant plugin on top), uninstall runs unblocked: the plugin drops all TenBruggencateMultiBrand.config.* rows from system_config and removes itself.

GEO / multi-region note โ€” why there is no hreflang

This plugin deliberately does NOT emit hreflang alternate links between the configured brand hostnames. That is correct, not an oversight:

  • hreflang is the signal for "the same canonical content is served in multiple languages or regions" โ€” e.g. example.com/product (en) vs. example.de/product (de) for the same product.
  • The domains MultiBrand is designed for are separate brands with their own catalogues, copy, and identity, not locale-variants of the same storefront. They just share a single Shopware instance for operational reasons.
  • Cross-linking them via hreflang would tell Google "these are equivalent pages, pick one" โ€” which is the opposite of what multi-brand storefronts want.

If your brands really ARE locale-variants (e.g. you have shop.nl / shop.de / shop.fr serving the same products in different languages), Shopware's native SalesChannelDomain per-language config is the right tool โ€” leave this plugin out of that use case.

Related plugins

Free Packagist-distributed siblings from the same publisher:

Support

License

MIT ยฉ ten Bruggencate Development

More from Ten Bruggencate Development

Free Lite plugins for Shopware 6.7, each with a Pro edition for when you need more. Overview: tenbruggencatedevelopment.nl/en/initiatieven/shopware-plugins

  • Analytics โ€” Matomo or Plausible, cookieless, with e-commerce events
  • Legal Pages โ€” terms, privacy, shipping and returns pages from templates ยท Pro: cookie consent, accessibility statement
  • Maintenance โ€” a branded, SEO-correct (HTTP 503) maintenance page
  • Multi-Brand โ€” several brands on one Shopware, recognised by domain
  • Newsletter โ€” GDPR-safe sign-up with double opt-in ยท Pro: campaigns and segments
  • Product Encyclopedia โ€” educational content pages linked to your products
  • Seasons โ€” scheduled theme variants (colours, typography, hero)
  • Social Login โ€” passwordless login link, Google and Facebook
  • Stock Alert โ€” "notify me when it's back" with double opt-in