I already had a webhook sync module for Drupal Commerce. Drupal uses entity hooks, queue workers, and alter hooks. For Sylius, none of that applies.
Sylius sits on Symfony. That means event subscribers, service decoration, console commands, and kernel.terminate. The plugin needed to feel like a Symfony bundle, not a ported Drupal module.
Two Event Systems for Two Entity Types
Products in Sylius are proper Sylius resources. They fire resource events: sylius.product.post_create, sylius.product.post_update, sylius.product_variant.post_create, etc. An event subscriber catches these:
class ProductEventSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'sylius.product.post_create' => 'onProductCreate',
'sylius.product.post_update' => 'onProductUpdate',
'sylius.product.pre_delete' => 'onProductDelete',
'sylius.product_variant.post_create' => 'onVariantCreate',
'sylius.product_variant.post_update' => 'onVariantUpdate',
'sylius.product_variant.pre_delete' => 'onVariantDelete',
];
}
}
When a variant changes, the subscriber re-syncs the entire parent product. The external service stores products with all their variations in one record, so a variant update means the whole product needs refreshing.
Pages work differently. Sylius has no built-in Page entity. Stores use BitBag CMS, custom entities, or nothing. These aren't Sylius resources, so they don't fire resource events. I used Doctrine lifecycle listeners instead:
#[AsDoctrineListener(event: Events::postPersist)]
#[AsDoctrineListener(event: Events::postUpdate)]
#[AsDoctrineListener(event: Events::preRemove)]
class PageDoctrineListener
{
public function postPersist(PostPersistEventArgs $args): void
{
$entity = $args->getObject();
if ($this->isTrackedPage($entity)) {
$this->queue('page.created', $entity);
}
}
}
The listener only fires if the entity implements PageInterface and its class is listed in the bundle config:
emporiqa:
page_entity_classes:
- App\Entity\Page
PageInterface is minimal — getId() and getTranslations(). Existing page entities from any CMS plugin can implement it without changing their schema.
This was a design tradeoff. I could have required a specific page entity structure, which would make the plugin easier to build but harder to adopt. Or I could make page sync entirely optional and interface-based, which is what I did. Most stores don't have pages at all, and the ones that do all structure them differently.
Deferred Sending via kernel.terminate
Sending HTTP requests during an admin save action is slow. The Drupal module uses queue workers. For Symfony, I used kernel.terminate.
Events aren't sent immediately when they fire. They go into a WebhookEventQueue — an in-request buffer:
class WebhookEventQueue implements EventSubscriberInterface
{
private array $queue = [];
public function queue(string $type, array $data): void
{
$key = $data['identification_number'];
// Delete always wins
if (str_ends_with($type, '.deleted')) {
$this->queue[$key] = ['type' => $type, 'data' => $data];
return;
}
// Don't overwrite a delete with an update
if (isset($this->queue[$key]) && str_ends_with($this->queue[$key]['type'], '.deleted')) {
return;
}
$this->queue[$key] = ['type' => $type, 'data' => $data];
}
}
The queue deduplicates by identification_number. If a product is saved twice in one request (bulk operations), only the last event goes out. All translations are consolidated into a single event per product. Delete always wins over create/update — if something is deleted, there's no point sending the create that preceded it.
On kernel.terminate, after the HTTP response is already sent to the browser, the queue flushes and sends everything in one batch. The admin UI stays fast. The webhook happens a few hundred milliseconds later.
This doesn't work for CLI commands though — there's no HTTP kernel in a console context. The sync commands send batches directly via WebhookSender.
Service Decoration for Customization
Where Drupal uses alter hooks, Symfony has service decoration.
The plugin formats products into webhook payloads via ProductFormatter. If a store has custom attributes (warranty, vehicle compatibility, material composition), the store developer decorates the formatter:
class CustomProductFormatter implements ProductFormatterInterface
{
public function __construct(
private ProductFormatterInterface $inner,
) {}
public function format(ProductInterface $product): array
{
$events = $this->inner->format($product);
foreach ($events as &$event) {
if ($product->hasAttribute('warranty_years')) {
// Attributes use {channel: {language: {name: value}}} nesting
foreach ($event['data']['attributes'] as $channel => &$languages) {
foreach ($languages as $lang => &$attrs) {
$attrs['warranty'] = $product
->getAttributeByCodeAndLocale('warranty_years', $lang)
?->getValue() . ' years';
}
}
}
}
return $events;
}
}
Register it in services.yaml with a #[AsDecorator] attribute or the standard Symfony decorates key. The original formatter becomes $inner, and your code wraps it.
For cases where you need to skip sync for specific entities or modify the payload batch before it's sent, the plugin dispatches Symfony events:
-
emporiqa.pre_sync(cancellable, fired before any entity syncs) -
emporiqa.post_format(fired after formatting, you can modify the payload before queuing) -
emporiqa.pre_webhook_send(fired before the HTTP batch, you can modify or filter events) -
emporiqa.order_tracking(fired after order lookup, you can modify the response) -
emporiqa.cart_operation(fired before a cart operation, cancellable)
Service decoration for structural changes, event listeners for conditional logic — both standard Symfony.
Cart Operations via Sylius's Own Services
The plugin provides cart API endpoints that the chat widget calls. The implementation uses Sylius's native services:
-
CartContextInterfaceto get the current cart -
OrderModifierInterfaceto add items -
OrderItemQuantityModifierInterfaceto update quantities
GET /emporiqa/api/cart → current cart state
POST /emporiqa/api/cart/add → add item by variation_id
POST /emporiqa/api/cart/update → update quantity
POST /emporiqa/api/cart/remove → remove item
POST /emporiqa/api/cart/clear → empty cart
GET /emporiqa/api/cart/checkout-url → redirect URL
GET /emporiqa/api/user-token → signed user token
No custom cart logic. The plugin delegates to whatever Sylius is already doing for cart management. If the store has custom cart rules (promotions, inventory checks), they still apply because the plugin goes through Sylius's own service layer.
Conversion Tracking
An event subscriber listens for Symfony Workflow events on order completion:
public static function getSubscribedEvents(): array
{
return [
'workflow.sylius_order_checkout.completed.complete' => 'onOrderComplete',
];
}
When a checkout completes, the subscriber reads an emporiqa_sid cookie (the chat session ID, set by the widget JavaScript), assembles an order.completed webhook payload with line items, totals, and currency, and queues it via WebhookEventQueue.
On the receiving end, this links the chat session to the purchase. The dashboard shows chat-attributed revenue.
The subscriber works with both Sylius 2.x (Symfony Workflow events) and 1.x (Winzou State Machine callbacks), so conversion tracking is available regardless of which version the store runs.
What Doesn't Work Well
Page sync is opt-in and requires effort. Since Sylius has no standard Page entity, every store needs to implement PageInterface on whatever they're using for content. Some stores skip page sync entirely because the effort isn't worth it for a handful of policy pages. I provide samples in the README, but it's still manual work.
PageUrlResolver is a stub. The plugin can't know how a store routes to its page entities. The default resolver returns empty strings. Stores need to override it with a service that knows their routing. This is the most common "it doesn't work" issue I hear about.
CLI sync doesn't use kernel.terminate. Console commands send batches directly, which means the deduplication logic in WebhookEventQueue doesn't apply. The sync commands handle batching themselves with a different code path. Two sending mechanisms for the same data, which isn't ideal.
The plugin is on Packagist: emporiqa/sylius-plugin. Sylius ^1.12 or ^2.0, PHP 8.1+. The setup docs walk through installation and configuration. I also wrote about the full integration story on the Emporiqa blog. You can see the product sync and cart working with a free sandbox at Emporiqa.
Top comments (0)