Shopware 6 CMS shopping experiences: A developer’s handbook

Shopware 6 provides robust capabilities for customizing your e-commerce storefront, enabling you to create unique Shopware 6 CMS shopping experiences through custom templates.

This guide focuses on the code aspects of defining custom templates for both the admin and storefront, emphasizing quick development by focusing only on the essential parts.

Why create Custom Elements?

They allow you to:

  • Extend functionality: Add unique features not available in default Shopware components.
  • Customize appearance: Tailor the look and feel of your storefront to match your brand.
  • Enhance User Experience: Optimize templates for faster load times and smoother interactions.

Prerequisites

Before you start, ensure you have the following:

  • A working Shopware 6 installation.
  • Basic knowledge of PHP, Twig, and Shopware 6 architecture.
  • Base Plugin/Theme where you want to create your custom components.

Important note

This tutorial focuses only on implementing elements, not blocks. You can swap existing blocks with your custom elements, which is often sufficient and saves you the development time and hassle of creating new blocks for every new element.

Step-by-step guide to creating Custom Elements

Administration side

The main entry point for customizing the Administration via plugin is the main.js file. It has to be placed into a <plugin root>/src/Resources/app/administration/src directory to be automatically found by Shopware.

Registering a New Element

Your plugin’s structure should always match the core Shopware structure. When you create a new element, you should recreate the file tree similar to the core for your plugin. Thus, recreate this structure in your plugin: <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements

This example creates a directory named hero.

Now create a new file index.js inside the hero directory, since it will be loaded when importing this element in your main.js. Right after having created the index.js file, you can import your new element’s directory in the main.js file already:

// <plugin root>/src/Resources/app/administration/src/main.js

import './module/sw-cms/elements/hero';

Now open up your empty hero/index.js file. To register a new element to the system, you have to call the method registerCmsElement of the cmsService. Since it’s available in the Dependency Injection Container, you can fetch it from there.

First, access the Application wrapper, which will grant you access to the DI container. So go ahead and fetch the cmsService from it and call the mentioned registerCmsElement method.

Go ahead and create this configuration object yourself. Here’s what it should look like after having set all of those options:

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/index.js

Shopware.Service('cmsService').registerCmsElement({
    name: 'hero',
    label: 'sw-cms.elements.customHeroElement.label',
    component: 'sw-cms-el-hero',
    configComponent: 'sw-cms-el-config-hero',
    previewComponent: 'sw-cms-el-preview-hero',
    defaultConfig: {
        title: {
            source: 'static',
            value: 'Hero title'
        }
    }
});

The registerCmsElement method takes a configuration object containing the following necessary data:

  • name: The technical name of your element, used for template loading.
  • label: A user-facing name for your element in the UI, preferably a snippet key.
  • component: The Vue component for rendering your actual element in the Administration.
  • configComponent: The Vue component defining the configuration detail page of your element.
  • previewComponent: The Vue component for the list of available elements, showing a tiny preview.
  • defaultConfig: Sets a default configuration for this element.
  • hidden (optional): Hides the element in the replace element modal.
  • removable (optional): Removes the replace element icon.

To create a translation snippet for the label, create a folder with the name snippet in your sw-cms folder. After that, create the files for the languages, e.g., de-DE.json and en-GB.json. The content of your snippet file should look something like this:

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/snippet/en-GB.json

{
  "sw-cms": {
    "elements": {
      "customHeroElement": {
        "label": "Hero element"
      }
    }
  }
}

Building the preview

Create the necessary Vue components. For the previewComponent, use a simple template, as this part is only faced by the admin and doesn’t need to be elaborate:

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/preview/index.js

import template from './sw-cms-el-preview-hero.html.twig';

Shopware.Component.register('sw-cms-el-preview-hero', { template });

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/preview/sw-cms-el-preview-hero.html.twig

{% block sw_cms_element_hero_preview %}
    <div class="sw-cms-el-preview-hero">
        Hero
    </div>
{% endblock %}

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/preview/sw-cms-el-preview-hero.scss

.sw-cms-el-preview-hero {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    text-align: center;
}

Creating the component

For the component, similarly as with the preview, keep it simple:

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/component/index.js

import template from './sw-cms-el-hero.html.twig';
import './sw-cms-el-hero.scss';

Shopware.Component.register('sw-cms-el-hero', { template });

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/component/sw-cms-el-hero.html.twig

{% block sw_cms_element_hero %}
    <div class="sw-cms-el-hero">
        Hero
    </div>
{% endblock %}

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/component/sw-cms-el-hero.scss

.sw-cms-el-hero {
    width: 100%;
    min-height: 300px;
    display: flex;
    font-size: 2rem;
    justify-content: center;
    align-items: center;
    color: white;
    mix-blend-mode: difference;
}

Import these components in your hero/index.js file:

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/index.js
import './preview';
import './component';
import './config';

Configuration options

Now you’re almost there! There is one last admin thing missing. Allow the user to configure the title you want to show. This happens in the configuration component.

Create configuration component:

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/config/index.js

import template from './sw-cms-el-config-hero.html.twig';

Shopware.Component.register('sw-cms-el-config-hero', {
    template,

    mixins: ['cms-element'],

    computed: {
        titleText: {
            get() {
                return this.element.config.titleText.value;
            },
            set(value) {
                this.element.config.titleText.value = value;
            }
        },
    },

    created() {
        this.createdComponent();
    },

    methods: {
        createdComponent() {
            this.initElementConfig('hero');
        },

        onTitleTextUpdate(value) {
            this.element.config.titleText.value = value;
            this.$emit('element-update', this.element);
        },
    }
});

In the configuration template, you’ll add every field that you want to be editable by the admin

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/config/sw-cms-el-config-hero.html.twig

{% block sw_cms_element_hero_config %}
    <sw-text-field
        v-model="titleText"
        label="Title text"
        placeholder="Enter title text..."
        @update:value="onTitleTextUpdate">
    </sw-text-field>
{% endblock %}

Make sure to import all the components in the root element index.js:

// <plugin root>/src/Resources/app/administration/src/module/sw-cms/elements/hero/index.js

import './preview';
import './component';
import './config';

Final steps

Build the administration assets clear cache:

./bin/build-administration.sh && ./bin/console cache:clear

Your custom element is ready for use in the Shopware 6 admin panel.

Storefront side

Each custom element’s storefront representation in Shopware 6 is expected in the directory platform/src/Storefront/Resources/views/storefront/element. For your custom element, you will need to create a Twig template named after same as the admin element. In this case, let’s create a template for an element named hero.

Create the Twig Template

Within this directory, create a new Twig template named cms-element-hero.html.twig. The template for this is straightforward, similar to the main component for the Administration.

 // <plugin root>/src/Resources/views/storefront/element/cms-element-hero.html.twig

{% block element_hero %}
    <div class="cms-element-hero">
        <h3 class="cms-element-hero-title">{{ element.config.titleText.value }}</h3>
    </div>
{% endblock %}

This template element renders the element’s title text, configured in the administration.

Apply Styles

Ensure the styles are applied for proper display.

Ensure Element Configuration

The URL is parsed using the Twig variable element, which is automatically available in your element’s template. The titleText value is retrieved from the element’s configuration.

These steps fully integrate and make your custom element functional on both the administration and storefront sides. Shop managers can select and configure your new element in the ‘Shopping Experiences’ module, and the system renders it in both the admin interface and the storefront.

Visual enhancements after rapid prototyping

For a more polished admin experience in Shopware 6, consider using images to represent your custom element in the preview. This approach enables administrators to swiftly identify and configure components without almost any additional development time.

Handling more advanced data type – Images

To extend the hero component functionality to handle image uploads and selections within the configuration, follow these steps:

Administration side

What to add to Config Template

// ... //

{% block sw_cms_element_hero_config_media_upload %}
    <sw-cms-mapping-field
        {% if VUE3 %}
        v-model:config="element.config.media"
        {% else %}
        v-model="element.config.media"
        {% endif %}
        :label="$tc('sw-cms.elements.image.label')"
        value-types="entity"
        entity="media"
    >
        <sw-media-upload-v2
            variant="regular"
            :upload-tag="uploadTag"
            :source="previewSource"
            :allow-multi-select="false"
            :default-folder="cmsPageState.pageEntityName"
            :caption="$tc('sw-cms.elements.general.config.caption.mediaUpload')"
            @media-upload-sidebar-open="onOpenMediaModal"
            @media-upload-remove-image="onImageRemove"
        />

        <template #preview="{ demoValue }">
            <div class="sw-cms-el-config-image__mapping-preview">
                <img
                    v-if="demoValue.url"
                    :src="demoValue.url"
                    alt=""
                />
                <sw-alert
                    v-else
                    class="sw-cms-el-config-image__preview-info"
                    variant="info"
                >
                    {{ $tc('sw-cms.detail.label.mappingEmptyPreview') }}
                </sw-alert>
            </div>
        </template>
    </sw-cms-mapping-field>

    <sw-upload-listener
        :upload-tag="uploadTag"
        auto-upload
        @media-upload-finish="onImageUpload"
    />
{% endblock %}

{% block sw_cms_element_hero_config_media_modal %}
    <sw-media-modal-v2
        v-if="mediaModalIsOpen"
        variant="regular"
        :caption="$tc('sw-cms.elements.general.config.caption.mediaUpload')"
        :entity-context="cmsPageState.entityName"
        :allow-multi-select="false"
        :initial-folder-id="cmsPageState.defaultMediaFolderId"
        @media-upload-remove-image="onImageRemove"
        @media-modal-selection-change="onSelectionChanges"
        @modal-close="onCloseModal"
    />
{% endblock %}

// ... //

Add to index.js

// CSS Import
import './sw-cms-el-config-hero.scss';

Shopware.Component.register('sw-cms-el-config-hero', {
		// ... //
		
    data() {
        return {
            mediaModalIsOpen: false,
            initialFolderId: null,
        };
    },

    computed: {
		    // ... //
    
        mediaRepository() {
            return this.repositoryFactory.create('media');
        },

        uploadTag() {
            return `cms-element-media-config-${this.element.id}`;
        },

        previewSource() {
            if (this.element.data && this.element.data.media && this.element.data.media.id) {
                return this.element.data.media;
            }

            return this.element.config.media.value;
        },
    },

    methods: {
		    // ... //

        async onImageUpload({ targetId }) {
            const mediaEntity = await this.mediaRepository.get(targetId);

            this.element.config.media.value = mediaEntity.id;
            this.element.config.media.source = 'static';

            this.updateElementData(mediaEntity);

            this.$emit('element-update', this.element);
        },

        onImageRemove() {
            this.element.config.media.value = null;

            this.updateElementData();

            this.$emit('element-update', this.element);
        },

        onCloseModal() {
            this.mediaModalIsOpen = false;
        },

        onSelectionChanges(mediaEntity) {
            const media = mediaEntity[0];
            this.element.config.media.value = media.id;
            this.element.config.media.source = 'static';

            this.updateElementData(media);

            this.$emit('element-update', this.element);
        },

        updateElementData(media = null) {
            const mediaId = media === null ? null : media.id;
            if (!this.element.data) {
                this.$set(this.element, 'data', { mediaId, media });
            } else {
                this.$set(this.element.data, 'mediaId', mediaId);
                this.$set(this.element.data, 'media', media);
            }
        },

        onOpenMediaModal() {
            this.mediaModalIsOpen = true;
        },
    }
});

What to add to element registration

Shopware.Service('cmsService').registerCmsElement({
		// ... //
    defaultConfig: {
				// ... //
        media: {
            source: 'static',
            value: null,
            required: true,
            entity: {
                name: 'media',
            },
        },
        // ... //
    }
});

Storefront side

To integrate media handling (in Shopware 6 CMS shopping experiences) in the storefront for your element, follow these steps:

Accessing media data

Access the data from element.data to display it in your storefront; here’s how it’s used in conjunction with sw_thumbnails.

{% sw_thumbnails 'cms-image-thumbnails' with {
    media: element.data.media,
    attributes: { 'class': 'cms-image' }
} %}

Unfortunately, before accessing the data you need to handle it

Implementing Data Resolver

Implement a data resolver to correctly resolve and enrich the media. Here’s an example tailored for an image type:

<?php declare(strict_types=1);

namespace Example\\\\Plugin\\\\DataResolver;

use Shopware\\\\Core\\\\Content\\\\Cms\\\\Aggregate\\\\CmsSlot\\\\CmsSlotEntity;
use Shopware\\\\Core\\\\Content\\\\Cms\\\\DataResolver\\\\CriteriaCollection;
use Shopware\\\\Core\\\\Content\\\\Cms\\\\DataResolver\\\\Element\\\\AbstractCmsElementResolver;
use Shopware\\\\Core\\\\Content\\\\Cms\\\\DataResolver\\\\Element\\\\ElementDataCollection;
use Shopware\\\\Core\\\\Content\\\\Cms\\\\DataResolver\\\\FieldConfig;
use Shopware\\\\Core\\\\Content\\\\Cms\\\\DataResolver\\\\ResolverContext\\\\EntityResolverContext;
use Shopware\\\\Core\\\\Content\\\\Cms\\\\DataResolver\\\\ResolverContext\\\\ResolverContext;
use Shopware\\\\Core\\\\Content\\\\Cms\\\\SalesChannel\\\\Struct\\\\ImageStruct;
use Shopware\\\\Core\\\\Content\\\\Media\\\\MediaDefinition;
use Shopware\\\\Core\\\\Content\\\\Media\\\\MediaEntity;
use Shopware\\\\Core\\\\Framework\\\\DataAbstractionLayer\\\\Search\\\\Criteria;

class ImageCmsElementResolver extends AbstractCmsElementResolver
{
    public function getType(): string
    {
        return 'hero';
    }

    public function collect(CmsSlotEntity $slot, ResolverContext $resolverContext): ?CriteriaCollection
    {
        $config = $slot->getFieldConfig();
        $mediaConfig = $config->get('media');

        if (!$mediaConfig || $mediaConfig->isMapped() || $mediaConfig->getValue() === null) {
            return null;
        }

        $criteria = new Criteria([$mediaConfig->getValue()]);

        $criteriaCollection = new CriteriaCollection();
        $criteriaCollection->add('media_' . $slot->getUniqueIdentifier(), MediaDefinition::class, $criteria);

        return $criteriaCollection;
    }

    public function enrich(CmsSlotEntity $slot, ResolverContext $resolverContext, ElementDataCollection $result): void
    {
        $config = $slot->getFieldConfig();
        $image = new ImageStruct();
        $slot->setData($image);

        if ($urlConfig = $config->get('url')) {
            if ($urlConfig->isStatic()) {
                $image->setUrl($urlConfig->getValue());
            }

            if ($urlConfig->isMapped() && $resolverContext instanceof EntityResolverContext) {
                $url = $this->resolveEntityValue($resolverContext->getEntity(), $urlConfig->getValue());
                if ($url) {
                    $image->setUrl($url);
                }
            }

            if ($newTabConfig = $config->get('newTab')) {
                $image->setNewTab($newTabConfig->getValue());
            }
        }

        $mediaConfig = $config->get('media');
        if ($mediaConfig && $mediaConfig->getValue()) {
            $this->addMediaEntity($slot, $image, $result, $mediaConfig, $resolverContext);
        }
    }

    private function addMediaEntity(CmsSlotEntity $slot, ImageStruct $image, ElementDataCollection $result, FieldConfig $config, ResolverContext $resolverContext): void
    {
        if ($config->isMapped() && $resolverContext instanceof EntityResolverContext) {
            /** @var MediaEntity|null $media */
            $media = $this->resolveEntityValue($resolverContext->getEntity(), $config->getValue());

            if ($media !== null) {
                $image->setMediaId($media->getUniqueIdentifier());
                $image->setMedia($media);
            }
        }

        if ($config->isStatic()) {
            $image->setMediaId($config->getValue());

            $searchResult = $result->get('media_' . $slot->getUniqueIdentifier());
            if (!$searchResult) {
                return;
            }

            /** @var MediaEntity|null $media */
            $media = $searchResult->get($config->getValue());
            if (!$media) {
                return;
            }

            $image->setMedia($media);
        }
    }
}

Ensuring correct media handling in your custom Shopware 6 storefront is crucial. Access the media data via element.data and implement a data resolver. These steps are necessary for integrating and displaying media content.

Conclusion

In this guide, the emphasis is on rapid prototyping with minimal code complexity when customizing Shopware 6 CMS shopping experiences. This approach enables swift iteration on both storefront and administration components, facilitating an efficient customization process.

Table of Contents