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)
);
}
}
}
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());
}
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
);
}
}
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
];
}
}
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;
}
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>
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);
}
}
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]);
}
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.