Shopware’s CMS lets merchandisers compose pages from slots and elements. A custom block (or element) is the right abstraction when marketing needs a repeatable pattern—hero + KPI row + product stream—without shipping PHP deploys for copy tweaks.
Below is the full shape I expect on projects: PHP data loading, Vue admin config, storefront render, and tests.
1. Terminology (do not mix these up)
- CMS page — URL route or layout assignment.
- Section / block — layout regions; your code may register a block containing one or more elements.
- Element — the thing with a
cms_slotconfig and a resolver.
Teams say “custom block” when they mean custom element—align language in README so new devs grep the right folders.
2. PHP: element definition + resolver
Registration (services.xml)
- Tag your resolver:
shopware.cms.data_resolverwithentityattribute matching your element name.
Resolver (CmsElementResolverInterface)
getType(): string identifier, e.g.my-brand-highlight-grid.collect(): add criteria for entities you need (products, media, categories)—Shopware batches these for performance.enrich(): hydrateElementDataCollectionwith real entities for the slot.
Performance
- Never query inside loops over slots without batching—use
collectaggregation patterns from core elements (product-slider, etc.) as reference.
Caching
- If resolver hits external HTTP (rare—avoid), cache with TTL in resolver context; CMS pages are HTTP-cached aggressively.
3. Admin: Vue component
File naming: sw-cms-el-my-brand-highlight-grid (follow Shopware conventions).
Config fields
sw-entity-single-selectforcategoryIdorproductStreamId.sw-media-uploadfor background.sw-text-fieldfor headline / legal footnote.
Validation
- Use
mapInheritance+cmsPageStatepatterns so layout assignments inherit sensibly.
Preview
- Wire
mixins.cms-elementso the admin preview shows real products when possible.
4. Storefront: Twig (or JS storefront)
Twig element template (theme)
- Path must match element name registered in theme’s
theme.jsonelement config. - Access hydrated data via
element.data(shape depends on resolver).
Example Twig loop (illustrative)
{% if element.data.products %}
<div class="cms-element-my-grid">
{% for product in element.data.products %}
{% sw_include '@Storefront/storefront/component/product/card/box.html.twig' with {
layout: 'standard',
displayMode: 'standard'
} %}
{% endfor %}
</div>
{% endif %}
JS storefront (if using Storefront JS stack)
- Register component mapping element name → Vue/Svelte component; lazy-load images.
5. Minimal PHP resolver sketch
public function collect(CmsSlotEntity $slot, ResolverContext $resolverContext): void
{
$config = $slot->getFieldConfig();
if (!$config || !$config->isProductStream()) {
return;
}
$streamId = $config->getProductStream()->getValue();
if (!$streamId) {
return;
}
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('streamId', $streamId));
// Add association graph you need for cards (cover, prices) — keep tight.
$slot->getData()->addProductStream('myStream', $streamId, $criteria);
}
(Exact APIs vary by Shopware minor version—compare to core ProductSliderCmsElementResolver.)
6. Testing strategy
- Unit: resolver criteria correctness (stream id missing → no collect).
- Integration: render CMS page via Storefront test router; assert HTML contains SKU you seeded.
- Visual: Percy/Chromatic on admin preview if marketing cares about WYSIWYG fidelity.
7. Failure modes we have seen
- Element works in default theme but not client child theme because
theme.jsondid not register template path. - Cross-selling slots accidentally pull wrong currency because
SalesChannelContextmissing in preview resolver.
Resume framing
“Shipped Shopware CMS elements with resolver batching, Vue admin config, storefront templates, and automated coverage—reduced merchandising deploy churn.”