DEV Community

Rosen Hristov
Rosen Hristov

Posted on

Building a Chat Module for PrestaShop: Hooks, Deferred Sending, and Combination Sync

I already had webhook sync modules for Drupal Commerce and Sylius. Each platform handles entity events differently. PrestaShop has its own approach: hooks, no message queue, and a combination system that doesn't map cleanly to variants.

PrestaShop's Hook System

PrestaShop uses hooks instead of event subscribers. For products, the key hooks are:

public function install()
{
    return parent::install()
        && $this->registerHook('actionProductSave')
        && $this->registerHook('actionProductDelete')
        && $this->registerHook('actionUpdateQuantity')
        && $this->registerHook('actionObjectCombinationAddAfter')
        && $this->registerHook('actionObjectCombinationUpdateAfter')
        && $this->registerHook('actionObjectCombinationDeleteAfter')
        && $this->registerHook('actionObjectCmsAddAfter')
        && $this->registerHook('actionObjectCmsUpdateAfter')
        && $this->registerHook('actionObjectCmsDeleteAfter')
        && $this->registerHook('displayHeader')
        && $this->registerHook('actionValidateOrder')
        && $this->registerHook('actionOrderStatusPostUpdate');
}
Enter fullscreen mode Exit fullscreen mode

The last three hooks handle widget embedding and conversion tracking. displayHeader auto-embeds the chat widget script on every front-office page. actionValidateOrder and actionOrderStatusPostUpdate handle the conversion pipeline (more on that below).

Products and CMS pages use different hook naming patterns. Products get actionProductSave (which fires on both create and update). CMS pages use the actionObject*After pattern with separate hooks for add, update, and delete. Combinations also use the actionObject*After pattern.

This is different from Drupal's OOP hook pattern (commerceProductInsert / commerceProductUpdate / commerceProductDelete), and from Sylius's sylius.product.post_create resource events. In PrestaShop, you register hooks in install() and implement them as class methods.

The Deferred Sending Problem

PrestaShop doesn't have a built-in message queue. Magento has MessageQueuePublisher. Shopware has Symfony Messenger. Sylius has kernel.terminate. PrestaShop has none of these.

Sending webhooks synchronously inside a hook would slow down every product save in the admin. Saving a product with 10 combinations might trigger 11 hook calls. If each one made an HTTP request, the admin would freeze.

The solution: deferred sending with register_shutdown_function.

class WebhookService
{
    private array $queuedEvents = [];
    private bool $shutdownRegistered = false;

    public function queueEvent(string $type, array $data): void
    {
        $this->queuedEvents[] = ['type' => $type, 'data' => $data];

        if (!$this->shutdownRegistered) {
            register_shutdown_function([$this, 'flushQueue']);
            $this->shutdownRegistered = true;
        }
    }

    public function flushQueue(): void
    {
        if (function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request();
        }

        // Now send all queued events
        foreach ($this->queuedEvents as $event) {
            $this->sendWebhook($event);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

register_shutdown_function runs after PHP finishes the main request. fastcgi_finish_request() (available on PHP-FPM) sends the response to the browser immediately, so the admin page loads while webhooks fire in the background.

This is the same concept as Sylius's kernel.terminate, but adapted for PrestaShop's architecture. Both achieve the same goal: don't make the user wait for HTTP requests they don't need to see.

Comparison: How Each Platform Handles Async Delivery

Platform Mechanism When it runs
Magento DB message queue + consumer Background worker process
Shopware Symfony Messenger + async transport Background worker process
Sylius kernel.terminate event After response sent, same process
PrestaShop register_shutdown_function After response sent, same process
Drupal Queue API + cron worker Background cron process

Sylius and PrestaShop both run in the same PHP process after the response. Magento, Shopware, and Drupal use separate worker processes. The tradeoff: same-process is simpler (no worker to manage) but ties webhook delivery to the web request lifecycle.

Deduplication

Saving a product with combinations can trigger actionProductSave multiple times in one request. Without dedup, you'd send the same product data three times.

private array $queuedProductIds = [];
private array $queuedPageIds = [];

public function hookActionProductSave($params)
{
    $productId = (int) $params['id_product'];

    if (isset($this->queuedProductIds[$productId])) {
        return;
    }

    $this->queuedProductIds[$productId] = true;
    $this->webhookService->queueEvent('product.updated', $this->formatProduct($productId));
}
Enter fullscreen mode Exit fullscreen mode

Simple array tracking per request. Product IDs and page IDs are tracked separately. This prevents the deferred queue from containing duplicate events for the same entity.

Combination Handling

PrestaShop's combination system is the trickiest part. A product can have combinations (Color: Red + Size: M, Color: Blue + Size: L, etc.). These are separate database records linked to the parent product.

The external service stores products with all variations in one record. So combination changes need to trigger a parent product re-sync:

public function hookActionObjectCombinationUpdateAfter($params)
{
    $combination = $params['object'];
    $productId = (int) $combination->id_product;

    // Re-sync the parent product (which includes all combinations)
    $this->syncProduct($productId);
}

public function hookActionObjectCombinationDeleteAfter($params)
{
    $combination = $params['object'];
    $productId = (int) $combination->id_product;

    // Send delete event for the specific variation
    $this->webhookService->queueEvent('product.deleted', [
        'identification_number' => 'variation-' . $combination->id,
    ]);

    // Re-sync the parent so it reflects the remaining combinations
    $this->syncProduct($productId);
}
Enter fullscreen mode Exit fullscreen mode

Deleting a combination requires two actions: a delete event for the variation itself, and a re-sync of the parent product so the external service knows which combinations remain. This is different from Magento's configurable products or Sylius's product variants, but the end result is the same.

Customization Hooks

PrestaShop doesn't have Symfony's service decoration or Magento's DI preferences. Instead, I used PrestaShop's own hook system for customization:

public function hookActionEmporiqaFormatProduct($params)
{
    // Other modules can modify product data before it's sent
    // $params['data'] is passed by reference (also receives 'product' and 'event_type')
}

public function hookActionEmporiqaShouldSyncProduct($params)
{
    // Other modules can prevent a product from syncing
    // Set $params['should_sync'] = false to skip
}
Enter fullscreen mode Exit fullscreen mode

Seven hooks total: actionEmporiqaShouldSyncProduct, actionEmporiqaShouldSyncPage, actionEmporiqaFormatProduct, actionEmporiqaFormatPage, actionEmporiqaFormatOrder, actionEmporiqaOrderTracking, actionEmporiqaWidgetParams.

This is PrestaShop's equivalent of Drupal's alter hooks, Sylius's service decoration, and Magento's DI preferences. Each platform uses its own extensibility mechanism. The goal is the same: let other code modify behavior without editing the module.

Admin Sync UI (No CLI)

PrestaShop doesn't have a standard CLI framework like Drush (Drupal), WP-CLI (WordPress), or bin/console (Symfony/Shopware). Magento has bin/magento. PrestaShop has nothing equivalent.

The initial sync uses an AJAX-powered admin UI instead:

  1. Click "Start Sync"
  2. JavaScript calls the module's admin controller with action=init
  3. Controller returns total product/page count
  4. JavaScript calls action=batch repeatedly with offset/limit
  5. Progress bar updates after each batch
  6. Final call to action=complete triggers reconciliation

This approach works well for PrestaShop's audience. Store owners are more likely to click a button in the admin than SSH into a server and run a CLI command.

Cart Operations

PrestaShop doesn't have a REST API like Magento's rest/V1/ or WooCommerce's /wp-json/wc/. For cart operations (add to cart, update quantity, remove, checkout), I used a front controller:

// modules/emporiqa/controllers/front/cartapi.php
class EmporiqaCartapiModuleFrontController extends ModuleFrontController
{
    public function initContent()
    {
        $action = Tools::getValue('action');

        match ($action) {
            'add' => $this->addToCart(),
            'remove' => $this->removeFromCart(),
            'update' => $this->updateQuantity(),
            'clear' => $this->clearCart(),
            'get' => $this->getCart(),
            'checkout-url' => $this->getCheckoutUrl(),
            default => $this->respondError('Unknown action'),
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

The chat widget's JavaScript calls these endpoints. Front controllers are PrestaShop's standard way to expose custom URLs from a module.

Conversion Tracking

The module tracks whether a chat session led to a purchase. When a customer places an order, actionValidateOrder fires and the module captures the chat session cookie from the request, storing the order-to-session mapping in a custom database table. Later, when the order reaches a completed status (payment accepted, delivered, etc.), actionOrderStatusPostUpdate fires and sends an order.completed webhook with the session ID, order total, and currency. This lets the external service attribute revenue to specific chat conversations without polling the store for order data.

What Doesn't Work Well

No background worker: The shutdown function approach works, but it means webhook delivery depends on the PHP process completing. If PHP times out or crashes, queued events are lost. Platforms with proper message queues (Magento, Shopware) handle this better.

Hook naming inconsistency: Products use actionProductSave while CMS pages use actionObjectCmsUpdateAfter. Combinations use actionObjectCombinationUpdateAfter. Three different patterns for the same concept. You just have to know which pattern each entity uses.

No built-in REST API: Every AJAX endpoint requires a front controller. Magento's webapi.xml and Shopware's route annotations are much cleaner.

Key Takeaway

Every platform has its own way of handling entity events, async processing, and extensibility. PrestaShop's approach (hooks + shutdown function + front controllers) is older and less structured than Symfony-based or Magento-based alternatives, but it works. The module syncs products and pages reliably, handles combinations correctly, and doesn't slow down the admin.

The full module is available for PrestaShop 8.0+ with PHP 8.1+. Documentation | GitHub. I wrote more about the full PrestaShop integration story on the Emporiqa blog.

Top comments (0)