Verified on Shopware 6.7
A custom Shopware theme that starts clean and stays clean three years later does not happen by accident. It is the result of deliberate decisions made early - about how SCSS is organized, how JavaScript plugins are registered, how Twig templates are extended, and how the theme coordinates with feature plugins without becoming entangled with them.
This guide walks through the structural decisions that make a Shopware theme scalable and intuitive to work in.
The Theme Plugin Skeleton
A Shopware theme is a standard plugin that implements ThemeInterface. The directory structure that scales well looks like this:
AcmeTheme/
├── composer.json
└── src/
├── AcmeTheme.php # Plugin class (implements ThemeInterface)
├── Storefront/
│ └── Subscriber/
│ └── ThemeConfigSubscriber.php
└── Resources/
├── config/
│ └── services.xml
├── snippet/
│ └── en_GB/
│ └── storefront.en-GB.json
├── theme.json # Theme manifest
├── views/ # Twig overrides
└── app/
├── administration/
│ └── src/snippet/
└── storefront/
├── build/
│ └── webpack.config.js
└── src/
├── main.js # JS entry point
├── plugin/ # JS plugins
├── scss/ # Styles
├── assets/ # Fonts, icons, images
└── utility/ # JS utilities
The key principle: everything the theme owns lives under src/Resources/app/storefront/. Twig templates live under src/Resources/views/. Nothing bleeds across those boundaries.
theme.json - The Theme Manifest
theme.json is the entry point for Shopware's theme compiler. It controls template resolution order, SCSS compilation order, JavaScript bundles, assets, and admin-configurable fields.
{
"name": "AcmeTheme",
"author": "Acme",
"views": [
"@Storefront",
"@AcmeTheme",
"@Plugins"
],
"style": [
"app/storefront/src/scss/breakpoints.scss",
"@StorefrontBootstrap",
"app/storefront/src/scss/overrides.scss",
"app/storefront/src/scss/base.scss",
"@Plugins"
],
"script": [
"@Storefront",
"app/storefront/dist/storefront/js/acme-theme/acme-theme.js"
],
"asset": [
"@Storefront",
"app/storefront/src/assets"
],
"configInheritance": [
"@Storefront",
"@AcmeTheme"
],
"iconSets": {
"acme": "app/storefront/src/assets/icon-pack/acme"
},
"config": {
"fields": {
"sw-font-family-base": {
"label": { "en-GB": "Base font" },
"type": "fontFamily",
"value": "'Inter', sans-serif",
"editable": true,
"block": "typography",
"order": 100
}
}
}
}
Views order matters. @AcmeTheme sitting after @Storefront means your templates override core templates. @Plugins at the end means feature plugins can extend your overrides. This is the correct order - do not move @Plugins above @AcmeTheme.
Style order matters too. breakpoints.scss loads before Bootstrap so your breakpoint variables are available to Bootstrap. overrides.scss loads before base.scss to set Bootstrap variable overrides before Bootstrap compiles them. @Plugins at the end ensures your theme styles have priority.
SCSS Architecture
The most common mistake in Shopware theme SCSS is a flat structure where everything ends up in one or two files. The structure that stays maintainable:
scss/
├── base.scss # Orchestrator - imports everything in order
├── breakpoints.scss # Loaded separately via theme.json (before Bootstrap)
├── overrides.scss # Bootstrap variable overrides (loaded before base)
├── abstract/
│ ├── variables/
│ │ ├── _colors.scss # Full color palette as SCSS vars + CSS custom properties
│ │ └── _commons.scss # Spacing, radius, shadows, transitions
│ ├── mixins/
│ │ ├── _components.scss # Button, card, tag mixins
│ │ ├── _grid.scss # Layout helpers
│ │ └── _utilities.scss # General helpers
│ └── classes/
│ ├── _buttons.scss # Button variants using mixins
│ └── _cards.scss # Card variants using mixins
├── base/
│ ├── _base.scss
│ └── _reboot.scss
├── component/
│ ├── _button.scss
│ ├── _modal.scss
│ ├── _product-box.scss
│ └── [one file per component]
├── layout/
│ ├── _header.scss
│ ├── _footer.scss
│ ├── _navigation.scss
│ └── _offcanvas-cart.scss
├── page/
│ ├── checkout/
│ │ ├── _cart.scss
│ │ ├── _confirm.scss
│ │ └── _finish.scss
│ ├── product-detail/
│ │ └── _product-detail.scss
│ └── account/
│ └── _order.scss
└── vendor/
└── _custom-fontface.scss
base.scss is purely an import file - it contains no styles of its own, only @use or @import directives in the correct order: vendor fonts, variables, mixins, classes, base, components, layout, pages.
Color System
Define colors as SCSS variables and immediately expose them as CSS custom properties:
// abstract/variables/_colors.scss
$primary-500: #3B82F6;
$primary-600: #2563EB;
:root {
-primary-500: #{$primary-500};
-primary-600: #{$primary-600};
}
This gives you SCSS variables for compile-time use and CSS custom properties for runtime access from JavaScript. Both from a single source of truth.
Mixin-Based Components
Instead of defining button styles directly, define a mixin and apply it to named classes:
// abstract/mixins/_components.scss
@mixin acme-btn($bg, $color, $font-size: 14px, $padding: 10px 20px) {
background-color: $bg;
color: $color;
font-size: $font-size;
padding: $padding;
border-radius: $common-radius-medium;
transition: background-color $common-transition-duration;
&:hover { background-color: darken($bg, 8%); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
// abstract/classes/_buttons.scss
.acme-btn-primary { @include acme-btn($primary-500, #fff); }
.acme-btn-secondary { @include acme-btn(transparent, $primary-500); }
.acme-btn-danger { @include acme-btn($error-500, #fff); }
When you need a new button variant, you add one line. When you change the button animation, you change one mixin.
JavaScript Plugin Architecture
Entry Point: main.js
main.js has one job - register and override plugins. No business logic here:
import ScaleBodyPlugin from './plugin/acme/scale-body.plugin';
import SaveScrollPositionPlugin from './plugin/acme/save-scroll-position.plugin';
import ConfirmTosPlugin from './plugin/acme/confirm-tos.plugin';
const PluginManager = window.PluginManager;
// Register new plugins
PluginManager.register('ScaleBodyPlugin', ScaleBodyPlugin, 'body');
PluginManager.register('SaveScrollPositionPlugin', SaveScrollPositionPlugin);
PluginManager.register('ConfirmTosPlugin', ConfirmTosPlugin, '#confirmOrderForm');
// Override Shopware core plugins with dynamic imports (code splitting)
PluginManager.override('SearchWidget',
() => import('./plugin/header/search-widget.override.plugin'));
PluginManager.override('Listing',
() => import('./plugin/listing/listing.override.plugin'));
PluginManager.override('OffCanvasFilter',
() => import('./plugin/offcanvas-filter/offcanvas-filter.override.plugin'));
New plugins are registered statically - they are always needed.
Overrides use dynamic imports - they are lazy-loaded and code-split automatically.
Naming Convention
Two distinct naming patterns for two distinct purposes:
feature-name.plugin.js- new plugin owned by the themefeature-name.override.plugin.js- extends an existing Shopware plugin
This makes it immediately clear at a glance whether a file introduces new behaviour or modifies existing behaviour.
Override Pattern
import SearchWidgetPlugin from 'src/plugin/header/search-widget/search-widget.plugin';
export default class SearchWidgetOverride extends SearchWidgetPlugin {
init() {
super.init();
this._addClearButton();
}
_addClearButton() {
// Add a clear (X) button to the search input
const clearBtn = document.createElement('button');
clearBtn.classList.add('search-clear-btn');
clearBtn.addEventListener('click', () => {
this._inputField.value = '';
this._inputField.dispatchEvent(new Event('input'));
});
this._inputField.parentNode.appendChild(clearBtn);
}
}
Always call super.init() first. Override only the methods you need to change.
Custom Webpack Configuration
Place a webpack.config.js in app/storefront/build/ to extend Shopware's webpack config. A common use case is replacing a core utility with a custom one:
const path = require('path');
const webpack = require('webpack');
module.exports = () => ({
plugins: [
new webpack.NormalModuleReplacementPlugin(
/src\/utility\/loading-indicator\/loading-indicator.util/,
path.resolve(__dirname + '/../src/utility/loading-indicator/loading-indicator.util.js')
)
]
});
This redirects all imports of Shopware's loading indicator to your custom implementation - without patching vendor code.
Twig Template Strategy
Rule: Extend, Never Replace
Always use sw_extends and {{ parent() }} unless you have a very specific reason not to:
{# Good - extends and selectively modifies #}
{% sw_extends '@Storefront/storefront/component/product/card/box-standard.html.twig' %}
{% block component_product_box_image %}
{{ parent() }}
<div class="acme-product-badge">New</div>
{% endblock %}
Replacing an entire template means every future Shopware update to that template requires manual merging. Extending means you only maintain the delta.
Attribute Injection Without Template Duplication
Sometimes you need to add an HTML attribute to a tag that is buried inside a block you do not want to fully override. Twig's string replace filter is the cleanest solution:
{% block element_product_listing_wrapper %}
{{ parent() | replace({
'data-listing' : 'data-results-total="' ~ searchResult.total ~ '" data-listing'
}) | raw }}
{% endblock %}
This injects a data attribute without duplicating the entire template block.
Template Organization in Views/
Mirror Shopware's template structure exactly:
views/storefront/
├── base.html.twig
├── component/
│ ├── product/card/
│ │ └── box-standard.html.twig
│ └── checkout/
│ └── offcanvas-cart.html.twig
├── layout/
│ ├── header/
│ │ └── header.html.twig
│ ├── footer/
│ │ └── footer.html.twig
│ └── navigation/
└── page/
├── checkout/
└── account/
When a developer needs to find the footer override, they look exactly where the Shopware footer template lives. No hunting.
Integrating Feature Plugins Cleanly
The most important architectural boundary to maintain: the theme owns design, plugins own features. They integrate via well-defined points, not by mixing concerns.
Configuration-Driven Includes
When the theme needs to include a plugin's component, gate it behind the plugin's config:
{% block component_product_box_image %}
{{ parent() }}
{% if config('AcmeCredits.config.showBalanceMeter') %}
{% sw_include '@AcmeCredits/storefront/component/balance-meter.html.twig' %}
{% endif %}
{% endblock %}
The plugin can be disabled without touching the theme template.
Plugin CSS Uses Theme Variables
Feature plugins should consume the theme's CSS custom properties, not hardcode values:
// In a feature plugin's SCSS
.balance-meter {
background-color: var(-primary-500); // From theme
border-radius: var(-common-radius-sm); // From theme
color: var(-neutral-900); // From theme
}
This means the feature plugin automatically adapts to theme customization without any additional work.
JavaScript Plugins Coexist
Shopware's PluginManager handles multiple plugins binding to the same element. Feature plugins register their own plugins independently:
// In a feature plugin's main.js
PluginManager.register('WishlistToggle', WishlistTogglePlugin, '[data-wishlist-toggle]');
// In the theme's main.js
PluginManager.register('ScaleBodyPlugin', ScaleBodyPlugin, 'body');
No coordination needed. Both run independently on their respective selectors.
Asset Organization
assets/
├── font/
│ └── inter/ # Custom font, all weights
│ ├── inter-regular.woff2
│ ├── inter-medium.woff2
│ └── inter-bold.woff2
├── icon-pack/
│ └── acme/ # Custom SVG icon set (name matches theme identifier)
│ ├── arrow-right.svg
│ ├── cart.svg
│ ├── user.svg
│ └── close.svg
└── illustration/ # Larger SVG illustrations
└── empty-state.svg
The icon pack folder name must match the key defined in theme.json under iconSets. Shopware's icon component uses this to resolve {% sw_icon 'arrow-right' %} to your custom SVG.
Font faces are declared in vendor/_fontface.scss and imported first in base.scss so they are available everywhere.
Checklist
Before shipping a theme for long-term development:
- [ ]
theme.jsonviews order:@Storefront→@AcmeTheme→@Plugins - [ ]
theme.jsonstyle order: breakpoints → Bootstrap → overrides → base →@Plugins - [ ] SCSS has
abstract/,component/,layout/,page/separation - [ ] Colors defined as SCSS variables AND CSS custom properties
- [ ] Reusable UI components use mixins, not repeated style blocks
- [ ] All Twig overrides use
sw_extends+{{ parent() }} - [ ] Twig directory mirrors Shopware's template directory structure
- [ ] JS new plugins:
feature.plugin.js/ overrides:feature.override.plugin.js - [ ] JS overrides use dynamic imports for code splitting
- [ ] Feature plugin includes are gated behind config checks
- [ ] Feature plugin SCSS consumes theme CSS custom properties
- [ ] Custom assets organized under
assets/font/,assets/icon-pack/,assets/illustration/