DEV Community

Rosen Hristov
Rosen Hristov

Posted on

Building a Chat Plugin for Shopware 6: Message Queues, Sales Channels, and Service Decoration

I already had webhook sync modules for Drupal Commerce, Sylius, and PrestaShop. Shopware 6 is the most complex of the four: sales channels, multi-currency, Symfony Messenger for async, and a Vue.js admin framework.

Shopware's Event Subscriber System

Shopware uses event subscribers similar to Sylius (both are Symfony-based), but with Shopware-specific event classes:

class ProductSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            'product.written' => 'onProductWritten',
            'product.deleted' => 'onProductDeleted',
        ];
    }

    public function onProductWritten(EntityWrittenEvent $event): void
    {
        foreach ($event->getIds() as $productId) {
            $this->webhookService->dispatch(
                new WebhookMessage('product.updated', $productId)
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Shopware fires EntityWrittenEvent which contains all entity IDs that were written in a single DAL operation. This is different from Sylius where each entity fires its own event. One Shopware write operation can contain multiple product IDs.

The OrderSubscriber listens for order state changes with configurable states:

public function onOrderStateChanged(StateMachineTransitionEvent $event): void
{
    if (!$this->isTrackedOrderState($event->getToPlace()->getTechnicalName())) {
        return;
    }

    // Check transaction state too (configurable)
    $this->trackConversion($event->getEntityId());
}
Enter fullscreen mode Exit fullscreen mode

Store owners configure which order states and transaction states trigger conversion events. A store might want to track on "completed" + "paid", while another tracks on "in_progress" + "authorized". This is managed through the admin dashboard, not code.

Message Queue: Symfony Messenger

Shopware uses Symfony Messenger for async processing. Compared to the other platforms I've integrated with, this one required the least custom code for reliable async delivery.

class WebhookMessage
{
    public function __construct(
        private readonly string $eventType,
        private readonly string $entityId,
        private readonly array $context = [],
    ) {}
}

class WebhookMessageHandler
{
    public function __invoke(WebhookMessage $message): void
    {
        $product = $this->productRepository->search(
            new Criteria([$message->getEntityId()]),
            Context::createDefaultContext()
        )->first();

        $data = $this->formatter->formatProduct(
            $product,
            $this->channelContextService->getContexts()
        );

        $this->webhookClient->send(
            $message->getEventType(),
            $data
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

When a product is saved, the subscriber dispatches a WebhookMessage. Symfony Messenger serializes it and puts it on the async transport. A background worker picks it up and the handler sends the webhook.

Comparison with Other Platforms

Platform Async mechanism Retry handling Worker management
Shopware Symfony Messenger Built-in retry + dead letter bin/console messenger:consume async
Magento DB message queue Built-in retry bin/magento queue:consumers:start
Sylius kernel.terminate None (same process) None needed
PrestaShop register_shutdown_function None (same process) None needed

Shopware and Magento have proper background workers with retry handling. If a webhook fails, the message goes back on the queue and gets retried. With Sylius and PrestaShop, a failed HTTP request during shutdown is just lost.

The tradeoff: Shopware and Magento require a running worker process. Sylius and PrestaShop don't. For production stores, a worker is already running for other tasks, so it's not additional overhead.

Sales Channel Architecture

This is where Shopware gets interesting. A single Shopware instance can have multiple sales channels: a B2C storefront, a B2B portal, an Amazon channel. Each channel has its own:

  • Languages (de-DE, en-GB, fr-FR)
  • Currencies (EUR, CHF, GBP)
  • Product assignments
  • Domains

The plugin needs to handle this. A product might be visible in the B2C channel at €49.99 (EUR) but in the B2B channel at €39.99 (EUR) with different descriptions.

class ProductFormatter implements ProductFormatterInterface
{
    public function formatProduct(
        ProductEntity $product,
        array $channelContexts,
        ?string $syncSessionId = null,
    ): array {
        $names = [];
        $descriptions = [];
        $prices = [];
        $channelKeys = [];

        foreach ($channelContexts as $channelKey => $context) {
            $channelKeys[] = $channelKey;

            foreach ($this->getChannelLanguages($context) as $language) {
                $translated = $this->translateProduct($product, $language);
                $lang = $language->getLocaleCode();
                $names[$channelKey][$lang] = $translated->getName();
                $descriptions[$channelKey][$lang] = $translated->getDescription();
            }

            foreach ($this->getChannelCurrencies($context) as $currency) {
                $price = $this->calculatePrice($product, $currency, $context);
                $prices[$channelKey][] = [
                    'current_price' => $price->getGross(), // or net, configurable
                    'regular_price' => $price->getListPrice()?->getGross(),
                    'currency' => $currency->getIsoCode(),
                ];
            }
        }

        return [
            'identification_number' => 'product-' . $product->getId(),
            'sku' => $product->getProductNumber(),
            'channels' => $channelKeys,
            'names' => $names,
            'descriptions' => $descriptions,
            'prices' => $prices,
            // ... categories, attributes, brands, images
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

The sales channel mapping is configured in the admin dashboard as a JSON map: Shopware channel ID → Emporiqa channel key. This lets store owners control which channels sync and how they're identified on the external service.

Service Interfaces and Decoration

Shopware uses Symfony's DI container. I defined interfaces for all core services:

interface ProductFormatterInterface
{
    public function formatProduct(
        ProductEntity $product, array $channelContexts, ?string $syncSessionId = null,
    ): array;
}

interface CmsPageFormatterInterface
{
    public function formatCmsPage(CmsPageEntity $cmsPage, array $channelContexts, ?string $syncSessionId = null): array;
    public function formatLandingPage(LandingPageEntity $landingPage, array $channelContexts, ?string $syncSessionId = null): array;
}

interface WebhookClientInterface
{
    public function send(string $eventType, array $data): void;
}

interface ConfigServiceInterface
{
    public function get(string $key, ?string $salesChannelId = null): mixed;
}

interface SyncServiceInterface
{
    public function syncProducts(?callable $progressCallback = null, bool $dryRun = false): array;
    public function syncPages(?callable $progressCallback = null, bool $dryRun = false): array;
}

interface ChannelResolverInterface
{
    public function resolveChannelKey(string $salesChannelId): string;
    public function getMapping(): array;
}
Enter fullscreen mode Exit fullscreen mode

Any of these can be decorated by another plugin:

<service id="YourPlugin\Service\CustomProductFormatter"
         decorates="Emporiqa\ShopwarePlugin\Service\ProductFormatter">
    <argument type="service"
             id="YourPlugin\Service\CustomProductFormatter.inner" />
</service>
Enter fullscreen mode Exit fullscreen mode

This is the same pattern as Sylius's service decoration. Both platforms use Symfony DI, so the extensibility model is identical. Magento uses DI preferences (di.xml), PrestaShop uses hooks.

Admin Dashboard: Vue.js Module

Shopware's admin is built with Vue.js. The plugin ships a Vue component for the admin dashboard with:

  • Test connection button
  • Sync dashboard with progress tracking (products and pages separately)
  • Sales channel mapping UI
  • Brand source selection (which property group to use as brand)
  • Order state configuration (which states trigger conversion events)
  • Data preview (see formatted product/page data before syncing)

The admin module communicates with 8 custom API endpoints:

Endpoint Description
POST /api/_action/emporiqa/sync Trigger sync (entity in body: products, pages, or all)
POST /api/_action/emporiqa/test-connection Test webhook connection
POST /api/_action/emporiqa/sync-overview Sync status and statistics
POST /api/_action/emporiqa/data-preview Preview formatted data
POST /api/_action/emporiqa/sales-channels Available sales channels
POST /api/_action/emporiqa/property-groups Property groups for brand mapping
POST /api/_action/emporiqa/order-states Available order states
POST /api/_action/emporiqa/save-settings Save dashboard settings

These are the same operations available via CLI (bin/console emporiqa:sync:products, etc.), just exposed through the admin API.

Storefront: Widget and Cart

The widget is injected via Shopware's storefront JS plugin system:

// emporiqa-widget.plugin.js
export default class EmporiqaWidgetPlugin extends Plugin {
    init() {
        const config = this.el.dataset;

        const script = document.createElement('script');
        script.src = `${config.emporiqaUrl}/chat/embed/`;
        script.dataset.storeId = config.storeId;
        script.dataset.language = config.language;
        script.dataset.currency = config.currency;
        script.dataset.channel = config.channel;
        document.head.appendChild(script);
    }
}
Enter fullscreen mode Exit fullscreen mode

Language, currency, and channel are injected from the StorefrontSubscriber, which reads them from the current SalesChannelContext. The widget automatically shows the right language and currency for each storefront domain.

Cart operations use storefront routes:

#[Route(path: '/emporiqa/api/cart/add', name: 'emporiqa.cart.add', methods: ['POST'])]
public function add(Request $request, SalesChannelContext $context): JsonResponse
{
    $this->cartService->add(
        $request->get('product_id'),
        $request->get('quantity', 1),
        $context
    );

    return new JsonResponse(['success' => true]);
}
Enter fullscreen mode Exit fullscreen mode

SEO URLs are registered so routes work with Shopware's URL rewriting.

What Doesn't Work Well

Shopware 6.6+ only: The plugin uses APIs introduced in 6.6.0. Stores on 6.5 or earlier would need significant changes. Shopware 5 is a completely different architecture.

CMS page model: Shopware's CMS pages are structured as sections → blocks → elements. Extracting readable text from this nested structure for a chat assistant requires walking the tree and concatenating element content. Landing pages work well. Regular shopping experiences (category layouts) are less useful because they contain product listings, not prose.

Admin module build process: Shopware's admin build system requires running bin/console plugin:refresh and sometimes a full admin build (bin/build-administration.sh) for Vue component changes to appear. During development, this adds friction compared to Magento's XML-based admin or PrestaShop's Smarty templates.

Wrapping Up

Shopware 6 has a well-structured architecture for this kind of integration. Symfony Messenger for async, service interfaces for extensibility, sales channel contexts for multi-tenant isolation, and a Vue.js admin for configuration. The complexity is in the sales channel model: products, prices, languages, and currencies all vary by channel. The plugin respects all of this, which means the chat assistant on each storefront only knows about that storefront's catalog and prices.

The plugin requires Shopware 6.6 or 6.7 with PHP 8.2+. Install it with Composer (composer require emporiqa/shopware-plugin) or upload the release zip from the GitHub repo via the Extension Manager. The Shopware Store listing is still pending review. Documentation and setup guide. I also wrote about the full Shopware integration on the Emporiqa blog.

Try the live demo at demo.emporiqa.com or create a free Emporiqa account with $25 of signup credit (~100 conversations). No card needed.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.