DEV Community

Rosen Hristov
Rosen Hristov

Posted on

Building a Sylius Plugin with Webhook Sync, Service Decoration, and kernel.terminate

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',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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];
    }
}
Enter fullscreen mode Exit fullscreen mode

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 formatForLanguage(ProductInterface $product, string $locale): array
    {
        $events = $this->inner->formatForLanguage($product, $locale);

        foreach ($events as &$event) {
            if ($product->hasAttribute('warranty_years')) {
                $event['data']['attributes']['warranty'] = $product
                    ->getAttributeByCodeAndLocale('warranty_years', $locale)
                    ?->getValue() . ' years';
            }
        }

        return $events;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.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)

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:

  • CartContextInterface to get the current cart
  • OrderModifierInterface to add items
  • OrderItemQuantityModifierInterface to 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
Enter fullscreen mode Exit fullscreen mode

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',
    ];
}
Enter fullscreen mode Exit fullscreen mode

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.

One caveat: this uses Symfony Workflow events, which is Sylius 2.x. On Sylius 1.x (which uses Winzou State Machine), this event never fires and conversion tracking doesn't work. I documented this rather than trying to support both state machine implementations.

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.

Conversion tracking only works on Sylius 2.x. The Symfony Workflow event doesn't exist on 1.x. I could add a Winzou State Machine callback, but I'd rather push people toward Sylius 2.x than maintain two state machine integrations.

The plugin is on Packagist: emporiqa/sylius-plugin. Sylius ^1.12 or ^2.0, PHP 8.1+. You can see the product sync and cart working with a free sandbox at Emporiqa.

Top comments (0)