PrestaShop Analytics Implementation Guide | OpsBlu Docs

PrestaShop Analytics Implementation Guide

Complete guide to implementing analytics on PrestaShop stores using the hook system, Smarty templates, custom modules, and GA4 ecommerce event tracking.

Analytics Architecture on PrestaShop

PrestaShop's analytics architecture is built around its hook system, Smarty template engine, and module framework. Every analytics integration in PrestaShop runs through one or more of these layers.

The hook system is PrestaShop's primary extension mechanism. Hooks are named injection points throughout the request lifecycle and template rendering process. For analytics, the critical hooks are: displayHeader (injects into <head>), displayBeforeBodyClosingTag (injects before </body>), actionCartSave (fires on cart modifications), actionValidateOrder (fires on order confirmation), and actionOrderStatusPostUpdate (fires on order status changes). A module registers for hooks, and PrestaShop calls the module's hook method at the appropriate time.

Smarty templates control PrestaShop's frontend HTML output. The header.tpl and footer.tpl files in your theme define the outer page structure. Smarty variables assigned in controllers are available in templates, and you can use {hook h='displayHeader'} to render all modules registered to that hook at a specific position. PrestaShop 1.7+ themes use the classic theme structure with _partials/head.tpl for head content.

The module system is the correct way to add analytics. A PrestaShop module is a PHP class that registers for hooks and provides configuration through the admin panel. Modules survive theme changes and PrestaShop upgrades. The official ps_googleanalytics module handles basic GA4 tracking, but it often lags behind the latest GA4 features and does not support custom data layer structures.

PrestaShop's Smarty cache compiles templates to PHP and caches the compiled output. When cache is enabled in Back Office > Advanced Parameters > Performance, template changes require a cache clear to take effect. This is template compilation caching, not page-level caching -- dynamic PHP and hook output still execute on every request.

Override system: PrestaShop allows overriding core classes, controllers, and modules by placing files in the /override/ directory. This is useful for modifying ecommerce tracking behavior without editing core module files, but overrides can conflict during upgrades and should be used sparingly.

Installing Tracking Scripts

Create a module that injects GTM via the displayHeader hook. Module structure:

modules/myanalytics/
  myanalytics.php
  config.xml
  views/templates/hook/
    header.tpl

The main module file myanalytics.php:

<?php

if (!defined('_PS_VERSION_')) {
    exit;
}

class MyAnalytics extends Module
{
    public function __construct()
    {
        $this->name = 'myanalytics';
        $this->tab = 'analytics_stats';
        $this->version = '1.0.0';
        $this->author = 'Your Company';
        $this->need_instance = 0;

        parent::__construct();

        $this->displayName = $this->l('Analytics & GTM');
        $this->description = $this->l('Injects GTM container and data layer into the storefront.');
    }

    public function install()
    {
        return parent::install()
            && $this->registerHook('displayHeader')
            && $this->registerHook('displayBeforeBodyClosingTag')
            && $this->registerHook('actionCartSave')
            && $this->registerHook('actionValidateOrder');
    }

    public function hookDisplayHeader($params)
    {
        $containerId = Configuration::get('MYANALYTICS_GTM_ID');
        if (empty($containerId)) {
            return '';
        }

        // Build data layer
        $dataLayer = $this->buildDataLayer();

        $this->context->smarty->assign([
            'gtm_container_id' => $containerId,
            'data_layer_json' => json_encode($dataLayer),
            'pending_events' => $this->getPendingEvents(),
        ]);

        return $this->display(__FILE__, 'views/templates/hook/header.tpl');
    }

    private function buildDataLayer()
    {
        $controller = Tools::getValue('controller');
        $data = [
            'pageType' => $controller,
            'shopLanguage' => $this->context->language->iso_code,
            'shopCurrency' => $this->context->currency->iso_code,
        ];

        // Product page data
        if ($controller === 'product') {
            $product = $this->context->controller->getTemplateVarProduct();
            $data['productId'] = (string) $product['id_product'];
            $data['productName'] = $product['name'];
            $data['productPrice'] = (float) $product['price_amount'];
            $data['productCategory'] = $product['category_name'];
            $data['productBrand'] = $product['manufacturer_name'] ?? '';
        }

        // Category page data
        if ($controller === 'category') {
            $category = $this->context->controller->getCategory();
            $data['categoryId'] = (string) $category->id;
            $data['categoryName'] = $category->name;
        }

        return $data;
    }

    private function getPendingEvents()
    {
        $events = $this->context->cookie->__get('analytics_events');
        if (!empty($events)) {
            $this->context->cookie->__unset('analytics_events');
            return json_decode($events, true) ?: [];
        }
        return [];
    }
}

The Smarty template views/templates/hook/header.tpl:

<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({$data_layer_json nofilter});
{if $pending_events}
{foreach from=$pending_events item=event}
window.dataLayer.push({$event|json_encode nofilter});
{/foreach}
{/if}
</script>

<script>
(function(w,d,s,l,i){ldelim}w[l]=w[l]||[];w[l].push({ldelim}'gtm.start':
new Date().getTime(),event:'gtm.js'{rdelim});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
{rdelim})(window,document,'script','dataLayer','{$gtm_container_id|escape:'htmlall':'UTF-8'}');
</script>

Note the {ldelim} and {rdelim} Smarty delimiters -- these are required because Smarty uses { and } as its own template delimiters. Without them, Smarty will try to parse the JavaScript object literals as template code.

Via Theme Template (Direct Edit)

Edit your theme's _partials/head.tpl (PrestaShop 1.7+) or header.tpl (1.6):

{* In themes/your_theme/templates/_partials/head.tpl *}
<head>
  <script>
  window.dataLayer = window.dataLayer || [];
  </script>
  <script>
  (function(w,d,s,l,i){ldelim}w[l]=w[l]||[];w[l].push({ldelim}'gtm.start':
  new Date().getTime(),event:'gtm.js'{rdelim});var f=d.getElementsByTagName(s)[0],
  j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
  j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
  f.parentNode.insertBefore(j,f);
  {rdelim})(window,document,'script','dataLayer','GTM-XXXXXX');
  </script>
  {block name='head_charset'}<meta charset="utf-8">{/block}
  {block name='head_hreflang'}{/block}
  <!-- rest of head -->
</head>

Data Layer Setup

Product Page Data Layer

Push detailed product information on product pages. In your module's hookDisplayHeader, the buildDataLayer method (shown above) handles this. The output on a product page:

window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  "pageType": "product",
  "shopLanguage": "en",
  "shopCurrency": "USD",
  "productId": "42",
  "productName": "Wireless Bluetooth Headphones",
  "productPrice": 79.99,
  "productCategory": "Electronics",
  "productBrand": "AudioTech"
});

Category Page with Product Impressions

For category (product listing) pages, push an item_list event with all visible products:

// Add to the buildDataLayer method
if ($controller === 'category') {
    $category = $this->context->controller->getCategory();
    $products = $this->context->controller->getTemplateVarProducts();

    $items = [];
    $index = 0;
    foreach ($products as $product) {
        $items[] = [
            'item_id' => (string) $product['id_product'],
            'item_name' => $product['name'],
            'price' => (float) $product['price_amount'],
            'item_category' => $category->name,
            'item_brand' => $product['manufacturer_name'] ?? '',
            'index' => $index++,
        ];
    }

    $data['categoryId'] = (string) $category->id;
    $data['categoryName'] = $category->name;
    $data['ecommerce'] = [
        'item_list_id' => 'category_' . $category->id,
        'item_list_name' => $category->name,
        'items' => $items,
    ];
}

Ecommerce Tracking

Add to Cart

Use the actionCartSave hook to capture cart modifications:

public function hookActionCartSave($params)
{
    if (!isset($this->context->cart) || !Tools::getValue('id_product')) {
        return;
    }

    $idProduct = (int) Tools::getValue('id_product');
    $idProductAttribute = (int) Tools::getValue('id_product_attribute');
    $qty = (int) Tools::getValue('qty');

    if ($qty <= 0) {
        return;
    }

    $product = new Product($idProduct, false, $this->context->language->id);
    $price = Product::getPriceStatic($idProduct, true, $idProductAttribute ?: null);
    $manufacturer = new Manufacturer($product->id_manufacturer, $this->context->language->id);
    $category = new Category($product->id_category_default, $this->context->language->id);

    $event = [
        'event' => 'add_to_cart',
        'ecommerce' => [
            'currency' => $this->context->currency->iso_code,
            'value' => round($price * $qty, 2),
            'items' => [[
                'item_id' => $product->reference ?: (string) $idProduct,
                'item_name' => $product->name,
                'price' => round($price, 2),
                'item_brand' => $manufacturer->name ?: '',
                'item_category' => $category->name ?: '',
                'quantity' => $qty,
            ]],
        ],
    ];

    $existing = json_decode($this->context->cookie->__get('analytics_events') ?: '[]', true);
    $existing[] = $event;
    $this->context->cookie->__set('analytics_events', json_encode($existing));
}

Purchase Event

Use actionValidateOrder to capture completed purchases:

public function hookActionValidateOrder($params)
{
    $order = $params['order'];
    $cart = new Cart($order->id_cart);
    $products = $cart->getProducts();

    $items = [];
    foreach ($products as $product) {
        $items[] = [
            'item_id' => $product['reference'] ?: (string) $product['id_product'],
            'item_name' => $product['name'],
            'price' => (float) $product['price_wt'],
            'quantity' => (int) $product['cart_quantity'],
            'item_brand' => $product['manufacturer_name'] ?? '',
            'item_category' => $product['category'] ?? '',
        ];
    }

    $event = [
        'event' => 'purchase',
        'ecommerce' => [
            'transaction_id' => $order->reference,
            'value' => (float) $order->total_paid_tax_incl,
            'tax' => round((float) $order->total_paid_tax_incl - (float) $order->total_paid_tax_excl, 2),
            'shipping' => (float) $order->total_shipping_tax_incl,
            'currency' => (new Currency($order->id_currency))->iso_code,
            'items' => $items,
        ],
    ];

    $existing = json_decode($this->context->cookie->__get('analytics_events') ?: '[]', true);
    $existing[] = $event;
    $this->context->cookie->__set('analytics_events', json_encode($existing));
}

The getPendingEvents method in hookDisplayHeader picks these up and pushes them into the data layer on the next page load (typically the order confirmation page).

Common Errors

Error Cause Fix
Smarty syntax error on GTM snippet JavaScript curly braces {} conflict with Smarty delimiters Use {ldelim} and {rdelim} for every JavaScript { and } in .tpl files, or wrap blocks in {literal}{/literal}
Data layer values show Smarty variable names instead of values The nofilter modifier is missing on {$data_layer_json} Add nofilter to prevent Smarty from escaping the JSON: {$data_layer_json nofilter}
Purchase event fires with transaction_id of "0" The actionValidateOrder hook receives the order before the reference is generated Use $order->reference instead of $order->id; if reference is empty, fall back to (string) $order->id
Add-to-cart event fires twice PrestaShop calls actionCartSave multiple times during a single add-to-cart action (once for validation, once for save) Check Tools::getValue('add') or Tools::getValue('action') to confirm it is an actual add action, not a cart recalculation
Module hooks not executing after install The module registered hooks during install() but the hook cache is stale Clear cache in Back Office > Advanced Parameters > Performance, or delete /var/cache/prod/ and /var/cache/dev/ directories
Tracking scripts missing on product pages only The displayHeader hook is registered but the theme does not include {hook h='displayHeader'} in the product template Check that your theme's product.tpl extends the base layout that includes the header hook, or register for displayTop as a fallback
Ecommerce values show tax-exclusive prices getPriceStatic defaults vary by context; the price may be tax-exclusive Pass true as the second parameter to Product::getPriceStatic($id, true) to get tax-inclusive prices
Cookie-based event queue loses events PrestaShop's cookie has a size limit; too many queued events overflow it Limit the queue to 5 events and use $this->context->cookie->write() after setting; for high-volume stores, use a database table instead
Data layer empty on first page load after cache clear Smarty compilation cache was cleared, and the first request compiles templates without hook output This is a one-time issue after cache clear; subsequent requests work correctly. No fix needed.
Module configuration lost after PrestaShop update Configuration values stored with Configuration::updateValue survive updates, but module files in /modules/ may be overwritten Always use Configuration::get() for settings; never hardcode values in module PHP files. Back up custom modules before updating.

Performance Considerations

  • Minimize hook registrations to only the hooks your module needs. Every registered hook adds a database query during page rendering. Register displayHeader and your ecommerce hooks; do not register displayProductButtons, displayProductAdditionalInfo, or other display hooks unless you actively render content there.

  • Use PrestaShop's built-in asset management via $this->context->controller->registerJavascript() instead of inline <script> tags when possible. Registered scripts benefit from combination and minification when CCC (Combine, Compress, Cache) is enabled in Performance settings.

  • Enable CCC (Combine, Compress, Cache) in Back Office > Advanced Parameters > Performance. This concatenates JavaScript files, reducing HTTP requests. Note that inline scripts from hooks are not combined -- only registered external files.

  • Avoid loading tracking libraries in actionCartSave or other action hooks. These hooks run during POST requests and redirects; they should only queue data, never render scripts. All script output must happen in display hooks like displayHeader.

  • Move secondary pixels to GTM instead of registering additional modules. Each analytics module adds its own hook calls, Smarty template rendering, and script output. A single GTM container with multiple tags is more efficient than five separate analytics modules.

  • Profile your module's hookDisplayHeader execution time. Use PrestaShop's debug profiler (define _PS_DEBUG_PROFILING_ in config/defines.inc.php) to check that your data layer construction does not add significant latency, especially on category pages where you iterate over product lists.