Introduction

This is a guide that explains how to develop a module-extension which extends already existing entities. If you wish to add additional entities to the output-module you should use the mapping-extension. You will learn how a module-extension is able to validate and manipulate the entities generated by the output-module. For this purpose we will develop an extension, which implements the following requirements:

  1. Customers without a birthday must not be transferred to Shopware
  2. Assignment of B2B roles to customers
  3. Assignment of contact persons to customers
  4. The property "Tax display" of customer groups must be set to "gross" for one specific customer group
  5. Information about packing stations must be added to the addresses of orders
  6. Manufacturer links must not be transferred to Shopware

Module Structure

A module extension is an independent module that does not have a progress definition and does not receive a "start message". Instead, you define one to many EventSubscriberInterfaces which subscribe to the events dispatched by the output-module.

The module extension will have the following structure after completing this guide:

Shopware6ExampleExtension
│   composer.json      
│   
└───src
    └───Config
    │      ExampleExtensionConfigProvider.php
    │
    └───DependencyInjection
    │      Shopware6ExampleExtensionExtension.php*
    │
    └───Output
    │      └───Customer
    │      │      CustomerB2bRoleSubscriber.php
    │      │      CustomerBirthdayValidationSubscriber.php
    │      │      CustomerContactPersonSubscriber.php
    │      │
    │      └───CustomerGroup
    │      │      CustomerGroupSubscriber.php
    │      │
    │      └───Order
    │             OrderAddressSubscriber.php
    │
    └───Resources
    │   └───config
    │   │       services.yaml 
    │   └───translations
    │           messages+intl-icu.en.php
    │
    └───Subscriber
           Shopware6ExampleExtensionBundleSubscriber.php

* The name of the extension class must end on "Extension", which is the reason for the "ExtensionExtension" in this case.

Preparations

First, the following elements need to be prepared:

  • composer.json
  • Shopware6ExampleExtensionExtension.php
  • Shopware6ExampleExtensionBundleSubscriber.php

composer.json

{
    "name": "synqup/shopware-6-example-extension",
    "description": "shopware 6 example extension",
    "type": "symfony-bundle",
    "version": "0.0.1",
    "authors": [
        {
            "name": "Daniel Rhode",
            "email": "dr@synqup.com"
        }
    ],
    "autoload": {
        "psr-4": {
            "Synqup\\Modules\\Shopware6ExampleExtensionBundle\\": "src/"
        }
    }
}  

Shopware6ExampleExtensionExtension.php

class Shopware6ExampleExtensionExtension extends Extension  
{  
    public function load(array $configs, ContainerBuilder $container) : void  
    {        
        $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('services.yaml');
}  

Shopware6ExampleExtensionBundleSubscriber.php

class Shopware6ExampleExtensionBundleSubscriber extends ModuleExtensionBaseSubscriber  
{  
    private MessageBusInterface $messageBus;

    public function __construct(JobDispatcherMappingRepository $dispatcherMappingRepository, MessageBusInterface $messageBus)
    {
        parent::__construct($dispatcherMappingRepository);
        $this->messageBus = $messageBus;
    }

    static function relevancyBasedSubscribedEvents(): array
    {
        return [];
    }

    public function modifyProgress(SubsectionProgressDefinition $progress, array $config): SubsectionProgressDefinition
    {
        return $progress;
    }
}

Requirement 1 - Validation of Birthdays

Synqup\Modules\Shopware6Bundle\Output\Core\Mapping\ValidationInfo\ValidationKeys

Documents of type Customer without a birthday should not be transferred to Shopware. This goal can be achieved by the implementation of a CustomerBirthdayValidationSubscriber that subscribes to the ShopwareEntityValidationEvent:

class CustomerBirthdayValidationSubscriber implements EventSubscriberInterface  
{  
    public static function getSubscribedEvents()
    {
        return [
            ShopwareEntityValidationEvent::class => 'onShopwareEntityValidation'
        ];
    }

    public function onShopwareEntityValidation(ShopwareEntityValidationEvent $event)
    {
        if (!$this->eventContainsCustomer($event)) {
            return;
        }

        /** @var Customer $sourceCustomer */
        $sourceCustomer = $event->getSourceObject();
        $birthday = $sourceCustomer->getCustomerInformation()?->getPersonalInformation()?->getDob() ?? null;
        if ($birthday === null) {
            $event->addValidationInfo(
                true,                                           # $invalidateEntity
                'missing_customer_birthday',                    # $technicalIdentifier
                'birthday',                                     # $targetFieldName
                'customerInformation.personalInformation.dob'   # $sourcePath
            );
        }
    }

    private function eventContainsCustomer(ShopwareEntityValidationEvent $event): bool
    {
        return ShopwareEntityNames::isEqual(ShopwareEntityNames::CUSTOMER, $event->getShopwareEntityName());
    }
}  

There are things in this example that should be looked at in more detail. The first thing are shopware entity names and the second is how to add translatable validation messages to our extension.

Names of Shopware Entities

The method eventContainsCustomer checks the name of the validated entity with the expression ShopwareEntityNames::isEqual(...). The reason for this are different formats of names that can occur in Shopware and thus throughout the module as well:

  • API requests require the name in the URL in kebab-case
  • API aliases of Shopware entities are delivered in snake_case
  • API associations are provided in camelCase

The ShopwareEntityNames::isEqual(...) method is used to ensure that different formats of the same entity name are considered equal
(e.g. customerAddress, customer_address and customer-address).

Validation Infos

You can attach information about your validation result to the ShopwareEntityValidationEvent by the help of the addValidationInfo method. The following parameters are available:

  • $invalidateEntity determines whether the entity will be flagged as invalid and therefore be ignored by the module.
  • $technicalIdentifier is the key used to provide a translatable validation message (see below) that contains the reason for your validation result.
  • $targetFieldName is optional and determines the name of the field of the Shopware entity that was validated.
  • $sourcePath is optional and specifies the path to the source value that was validated.

The parameters $targetFieldName and $sourcePath are optional. They exist to improve the readability of validation infos in logs and the frontend.

You may have noticed the file messages+intl-icu.en.php in the module structure. This is the file that contains the validation keys. In our case it should look like this:

<?php

return [
    'missing_customer_birthday' => 'Birthdays must not be zero',
];

The key missing_customer_birthday added to the event will generate a validation info with the translated message provided by this file.

Please refer to the symfony documentation for more information about translations.

Requirement 2 - Assigning B2B Flags to Customers

The second requirement is to assign B2B flags to generated customers. For this purpose we implement a subscriber for the
ShopwareEntityTransformedEvent. This subscriber is then used to update the customer that was generated by the module.

class CustomerB2bRoleSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents()
    {
        return [
            ShopwareEntityTransformedEvent::class => 'onShopwareEntityTransformed'
        ];
    }

    public function onShopwareEntityTransformed(ShopwareEntityTransformedEvent $event)
    {
        if (!ShopwareEntityNames::isEqual(ShopwareEntityNames::CUSTOMER, $event->getShopwareEntityName())) {
            return;
        }

        $shopwareCustomer = $event->getTransformedEntity();

        // b2b data
        $shopwareCustomer['b2bCustomerData'] = [
            'isDebtor'              => true,
            'isSalesRepresentative' => true,
            'customerId'            => $shopwareCustomer['id']
        ];

        $event->setTransformedEntity($shopwareCustomer);
    }

}

This is a fairly simple example. But it shows the general way how we can extend entities:

  1. Subscribe to the ShopwareEntityTransformedEvent event
  2. Check if the generated entity corresponds to the type of entity to be manipulated (by comparing names)
  3. Manipulate the Shopware entity according to your use case
  4. Update the processed entity in the event

Requirement 3 - Assignment of Contact Persons to Customers

The next step is to create a CustomerContactPersonSubscriber that assigns contact persons (e.g. provided by a plugin) to customers.
For this requirement we assume that an import module extends the document Customer with the CustomerCustomisation
extension. The procedure we learned in requirement 2 is applied to a more complex example now:

class CustomerContactPersonSubscriber implements EventSubscriberInterface
{
    public const CONTACT_PERSON_INTERNAL_CUSTOM_FIELD_NAME = 'e_contact_person_number_internal';
    public const CONTACT_PERSON_EXTERNAL_CUSTOM_FIELD_NAME = 'e_contact_person_number_external';

    public static function getSubscribedEvents()
    {
        return [
            ShopwareEntityTransformedEvent::class => 'onShopwareEntityTransformed'
        ];
    }

    public function onShopwareEntityTransformed(ShopwareEntityTransformedEvent $event)
    {
        // check if the transformed entity is a customer
        if (!ShopwareEntityNames::isEqual(ShopwareEntityNames::CUSTOMER, $event->getShopwareEntityName())) {
            return;
        }

        // ignore the entity if the source customer does not provide the extension that includes the customer contacts
        /** @type Customer $synqupCustomer */
        $synqupCustomer = $event->getSourceObject();

        /** @type MicrotechCustomerCustomisation $customerCustomisationExtension */
        $customerCustomisationExtension = $this->getCustomerCustomisationExtension($synqupCustomer);
        if ($customerCustomisationExtension === null) {
            return;
        }

        // add the customer contacts to the custom fields of the transformed shopware customer
        $shopwareCustomer = $event->getTransformedEntity();
        $customFields = $shopwareCustomer['customFields'] ?? [];

        /** @var MicrotechCustomerContact $customerContactInternal */
        $customerContactInternal = $customerCustomisationExtension->getOfficeCustomerContact() ?? null;
        if ($customerContactInternal !== null && $this->hasValidIdentifier($customerContactInternal)) {
            $customFields[self::CONTACT_PERSON_INTERNAL_CUSTOM_FIELD_NAME] = $customerContactInternal->getIdentifier();
        }

        /** @var MicrotechCustomerContact $customerContactExternal */
        $customerContactExternal = $customerCustomisationExtension->getFieldCustomerContact() ?? null;
        if ($customerContactExternal !== null && $this->hasValidIdentifier($customerContactExternal)) {
            $customFields[self::CONTACT_PERSON_EXTERNAL_CUSTOM_FIELD_NAME] = $customerContactExternal->getIdentifier();
        }

        // update the transformed entity
        $shopwareCustomer['customFields'] = $customFields;
        $event->setTransformedEntity($shopwareCustomer);
    }

    private function hasValidIdentifier(MicrotechCustomerContact $contact): bool
    {
        $identifier = $contact->getIdentifier() ?? null;
        return is_string($identifier) && $identifier !== '';
    }

    private function getCustomerCustomisationExtension(Customer $customer): ?MicrotechCustomerCustomisation
    {
        foreach ($customer->getExtensions() ?? [] as $extension) {
            if ($extension instanceof MicrotechCustomerCustomisation) {
                return $extension;
            }
        }
        return null;
    }

}

Of course, it would also be possible to assign the contact persons and B2B flags in the same subscriber. In this example we use two subscribers to demonstrate that multiple subscribers for the same entity are possible.

Requirement 4 - Manipulate Tax Display of Customer Groups

The next requirement is to set the tax display of a customer group to "gross". The customer group to change must be configurable.
Therefore, we will first discuss how to create an individual configuration for a module extension.

Provide a Configuration for Extensions

The module extension is automatically recognized and executed. If you need to provide a configuration for your extension you can do
this via the configuration of the output-module. Simply add your configuration to extensions and use the FQCN of the BundleSubscriber as extension key. In this case we define the field customerGroupIdentifier to set the customer group whose tax representation should be set to "gross".

{
    "extensions": {
        "Synqup\\Modules\\Shopware6ExampleExtensionBundle\\Subscriber\\Shopware6ExampleExtensionBundleSubscriber": {
            "customerGroupIdentifier": "1"
        }
    },
    "lastSync": "...",
    "batchSizes": {},
    "shopwareApi": {},
    "locales": {},
    "identifier": {},
    "subsections": {}
}  

Finally, the configuration can be accessed via the context. In this case we create the class ExampleExtensionConfigProvider for a more convenient access of our configuration (this not required at all):

class ExampleExtensionConfigProvider
{
    public const EXTENSION_KEY = "Synqup\\Modules\\Shopware6ExampleExtensionBundle\\Subscriber\\Shopware6ExampleExtensionBundleSubscriber";

    public static function getConfig(ModuleJobDispatchContext $context): array
    {
        return $context->getConfig()['extensions'][self::EXTENSION_KEY];
    }

}

ExampleCustomerGroupSubscriber.php

Now we can access our configuration in our subscriber. For this we implement the ExampleCustomerGroupSubscriber. Apart from the configuration it follows the same pattern as the previously created subscribers.

class CustomerGroupSubscriber implements EventSubscriberInterface
{

    public static function getSubscribedEvents()
    {
        return [
            ShopwareEntityTransformedEvent::class => 'onShopwareEntityTransformed'
        ];
    }

    public function onShopwareEntityTransformed(ShopwareEntityTransformedEvent $event)
    {
        if (!ShopwareEntityNames::isEqual(ShopwareEntityNames::CUSTOMER_GROUP, $event->getShopwareEntityName())) {
            return;
        }

        $extensionConfig = ExampleExtensionConfigProvider::getConfig($event->getContext());
        $customerGroupIdentifier = $extensionConfig['customerGroupIdentifier'];

        /** @type CustomerGroup $synqupCustomerGroup */
        $synqupCustomerGroup = $event->getSourceObject();
        $shopwareCustomerGroup = $event->getTransformedEntity();
        $shopwareCustomerGroup['displayGross'] = $synqupCustomerGroup->getIdentifier() === $customerGroupIdentifier;
        $event->setTransformedEntity($shopwareCustomerGroup);
    }

}

Requirement 5 - Extend Addresses by Packing Stations

This requirement concerns addresses of orders. If the delivery address of an order is a packing station, both the postal number and packing station number should be written into a CustomField. For this task we implement the OrderAddressSubscriber.

Since the OrderAddressEntity is an "embedded entity" we are faced with the problem that there is no ShopwareEntityTransformedEvent
we can subscribe to. That's why we are using the ShopwareEntityGeneratedEvent in this case. The reason for this is the structure of
the Shopware entities: An OrderAddressEntity is transformed together with the parent OrderEntity, so the
ShopwareEntityTransformedEvent will only be generated for the OrderEntity in this case.

class OrderAddressSubscriber implements EventSubscriberInterface
{
    private const PACKSTATION_NUMBER_CUSTOM_FIELD = 'dhl_packstation_address_packstation_number';
    private const POST_NUMBER_CUSTOM_FIELD = 'dhl_packstation_address_post_number';

    public static function getSubscribedEvents()
    {
        return [
            ShopwareEntityGeneratedEvent::class => 'onShopwareEntityGenerated'
        ];
    }

    public function onShopwareEntityGenerated(ShopwareEntityGeneratedEvent $event)
    {
        // check shopware entity type
        if (!$event->getGeneratedEntity() instanceof OrderAddressEntity) {
            return;
        }

        // check source address type
        if (!$event->getSourceObject() instanceof DHLPackstationAddress) {
            return;
        }

        // read generated entity
        /** @type OrderAddressEntity $shopwareAddress */
        $shopwareAddress = $event->getGeneratedEntity();

        // get the shipping address of the order
        /** @var DHLPackstationAddress $synqupAddress */
        $synqupAddress = $event->getSourceObject();

        // add station number as custom field
        $stationNumber = $synqupAddress->getPackstationNumber() ?? null;
        if(!empty($stationNumber)) {
            $this->addCustomFieldValue($shopwareAddress, self::PACKSTATION_NUMBER_CUSTOM_FIELD, $stationNumber);
        }

        // add post number as custom field
        $postNumber = $synqupAddress->getPostNumber() ?? null;
        if(!empty($postNumber)) {
            $this->addCustomFieldValue($shopwareAddress, self::POST_NUMBER_CUSTOM_FIELD, $postNumber);
        }

        // update the generated entity
        $event->setGeneratedEntity($shopwareAddress);
    }

    private function addCustomFieldValue(OrderAddressEntity $shopwareAddress, string $fieldName, string $value): void
    {
        $customFields = $shopwareAddress->getCustomFields() ?? [];
        $customFields[$fieldName] = $value;
        $shopwareAddress->setCustomFields($customFields);
    }

}

Requirement 6 - Manufacturer links must not be transferred to Shopware

In the next example, the links from manufacturers should not be sent to Shopware, because they are maintained by the customer manually.


class ManufacturerLinkSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents()
    {
        return [
            ShopwareEntityTransformedEvent::class => 'onShopwareEntityTransformed'
        ];
    }

    public function onShopwareEntityTransformed(ShopwareEntityTransformedEvent $event)
    {
        if (!ShopwareEntityNames::isEqual(ShopwareEntityNames::PRODUCT_MANUFACTURER, $event->getShopwareEntityName())) {
            return;
        }

        $outgoingManufacturer = $event->getTransformedEntity();
        unset($outgoingManufacturer['link']);

        $event->setTransformedEntity($outgoingManufacturer);
    }

}