📊 DataLayer + GTM — modulo de analytics real

Actualizado: 2026-04
ℹ️
Ejemplo basado en codigo real

Este ejemplo esta basado en el modulo datalayertagmanager, un modulo de produccion que implementa DataLayer GA4, Facebook CAPI, Google Ads, cookies GDPR, y envio server-side via Measurement Protocol.

#Patron: modulo de tracking completo

Un modulo de analytics/tracking es uno de los mas complejos en PrestaShop porque necesita engancharse a practicamente todas las paginas y eventos. Este patron muestra como organizar un modulo que:

#ObjectModel para pedidos tracked

Necesitamos saber que pedidos ya se enviaron a Google Analytics (para evitar duplicados y para el envio via cron):

classes/DataLayerOrder.php
php
<?php
if (!defined('_PS_VERSION_')) { exit; }

class DataLayerOrder extends ObjectModel
{
    public $id;
    public $id_order;
    public $sent;       // 0 = pendiente, 1 = enviado via JS, 2 = enviado via Cron
    public $sendgv4;    // Enviado a GA4 server-side
    public $date_add;

    public static $definition = [
        'table'   => 'datalayer_orders',
        'primary' => 'id',
        'fields'  => [
            'id_order' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true],
            'sent'     => ['type' => self::TYPE_INT, 'validate' => 'isInt'],
            'sendgv4'  => ['type' => self::TYPE_INT, 'validate' => 'isInt'],
            'date_add' => ['type' => self::TYPE_DATE, 'validate' => 'isDate'],
        ],
    ];

    /**
     * Marcar un pedido como trackeado
     */
    public static function markAsSent(int $idOrder, int $method = 1): bool
    {
        return Db::getInstance()->update(
            'datalayer_orders',
            ['sent' => $method],
            'id_order = ' . (int) $idOrder
        );
    }

    /**
     * Obtener pedidos pendientes de enviar via cron
     */
    public static function getPendingOrders(int $limit = 50): array
    {
        return Db::getInstance()->executeS('
            SELECT d.*, o.reference, o.total_paid_tax_incl, o.id_currency
            FROM ' . _DB_PREFIX_ . 'datalayer_orders d
            JOIN ' . _DB_PREFIX_ . 'orders o ON o.id_order = d.id_order
            WHERE d.sent = 0
            ORDER BY d.date_add ASC
            LIMIT ' . (int) $limit
        );
    }
}

#Install: 30+ hooks + tabla + configuracion

install() — registrar hooks masivamente
php
public function install()
{
    // 1. Crear tabla
    Db::getInstance()->execute('
        CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'datalayer_orders` (
            `id`       INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
            `id_order` INT(10) UNSIGNED NOT NULL,
            `sent`     TINYINT(1) NOT NULL DEFAULT 0,
            `sendgv4`  TINYINT(1) NOT NULL DEFAULT 0,
            `date_add` DATETIME NOT NULL,
            PRIMARY KEY (`id`),
            KEY `id_order` (`id_order`),
            KEY `sent` (`sent`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    ');

    // 2. Configuracion por defecto
    $defaults = [
        'DATALAYER_GTM_ID'        => 'GTM-XXXXXX',
        'DATALAYER_GA4_ID'        => 'G-XXXXXX',
        'DATALAYER_SEND_METHOD'   => '2',      // 1=JS, 2=Cron
        'DATALAYER_COOKIE_ACTIVE' => false,
        'DATALAYER_FB_PIXEL'      => '',
        'DATALAYER_TOKEN'         => Tools::passwdGen(24),
    ];
    foreach ($defaults as $key => $val) {
        Configuration::updateValue($key, $val);
    }

    // 3. Registrar TODOS los hooks necesarios
    $hooks = [
        // Head y body
        'displayHeader', 'displayFooter', 'displayBeforeBodyClosingTag',
        // Ecommerce events
        'actionValidateOrder', 'actionCartSave', 'actionOrderReturn',
        'actionOrderStatusPostUpdate',
        // Checkout flow
        'displayPaymentTop', 'displayCheckoutSummaryTop',
        'displayPersonalInformationTop', 'actionCarrierProcess',
        // Product & listing events
        'displayFooterProduct', 'actionSearch', 'actionProductSearchAfter',
        // User events
        'actionAuthentication', 'actionCustomerAccountAdd',
        'displayCustomerAccountForm', 'actionNewsletterRegistrationAfter',
        // Order confirmation
        'displayOrderConfirmation', 'displayOrderConfirmation1',
        'displayOrderDetail',
        // Admin
        'displayAdminOrder',
    ];

    return parent::install() && $this->registerHook($hooks);
}

#Inyectar dataLayer en todas las paginas

hookDisplayHeader — GTM snippet + dataLayer base
php
public function hookDisplayHeader()
{
    $gtmId = Configuration::get('DATALAYER_GTM_ID');
    if (empty($gtmId)) return '';

    // Contexto basico para todas las paginas
    $dataLayer = [
        'pageType'    => $this->context->controller->php_self ?? 'other',
        'shopCurrency'=> $this->context->currency->iso_code,
        'shopLanguage'=> $this->context->language->iso_code,
    ];

    // User ID (si autenticado y configurado)
    if (Configuration::get('DATALAYER_USERID') && $this->context->customer->isLogged()) {
        $dataLayer['userId'] = (string) $this->context->customer->id;
        $dataLayer['userEmail'] = hash('sha256', strtolower($this->context->customer->email));
    }

    // Inyectar en JS
    Media::addJsDef(['dataLayerBase' => $dataLayer]);

    // GTM snippet en <head>
    $this->context->smarty->assign([
        'gtm_id'     => $gtmId,
        'datalayer'  => json_encode($dataLayer),
    ]);

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

#Tracking de pedidos con actionValidateOrder

hookActionValidateOrder — registrar pedido para tracking
php
public function hookActionValidateOrder($params)
{
    $order = $params['order'];
    if (!Validate::isLoadedObject($order)) return;

    // Insertar en nuestra tabla de tracking
    $dl = new DataLayerOrder();
    $dl->id_order = (int) $order->id;
    $dl->sent = 0;     // Pendiente
    $dl->sendgv4 = 0;  // Pendiente GA4 server-side
    $dl->date_add = date('Y-m-d H:i:s');
    $dl->add();
}

#Eventos GA4 ecommerce

hookDisplayOrderConfirmation — purchase event
php
public function hookDisplayOrderConfirmation($params)
{
    $order = $params['order'] ?? null;
    if (!$order) return '';

    $products = $order->getProducts();
    $items = [];
    foreach ($products as $p) {
        $items[] = [
            'item_id'   => $p['product_reference'] ?: $p['product_id'],
            'item_name' => $p['product_name'],
            'price'     => (float) $p['unit_price_tax_incl'],
            'quantity'  => (int) $p['product_quantity'],
            'item_category' => $this->getCategoryName((int) $p['id_category_default']),
        ];
    }

    $purchaseData = [
        'event'          => 'purchase',
        'ecommerce'      => [
            'transaction_id' => $order->reference,
            'value'          => (float) $order->total_paid_tax_incl,
            'tax'            => (float) ($order->total_paid_tax_incl - $order->total_paid_tax_excl),
            'shipping'       => (float) $order->total_shipping_tax_incl,
            'currency'       => Currency::getIsoCodeById((int) $order->id_currency),
            'items'          => $items,
        ],
    ];

    // Si se envia por metodo cron, no emitir JS (evitar duplicados)
    if ((int) Configuration::get('DATALAYER_SEND_METHOD') === 2) {
        return ''; // Se enviara via Measurement Protocol
    }

    $this->context->smarty->assign('purchase_data', json_encode($purchaseData));
    return $this->display(__FILE__, 'views/templates/hook/purchase.tpl');
}

private function getCategoryName(int $idCategory): string
{
    $cat = new Category($idCategory, $this->context->language->id);
    return Validate::isLoadedObject($cat) ? $cat->name : '';
}

#Envio de pedidos via Cron + Measurement Protocol

controllers/front/send.php — endpoint cron
php
<?php
class DataLayerTagManagerSendModuleFrontController extends ModuleFrontController
{
    public function initContent()
    {
        // Verificar token de seguridad
        $token = Tools::getValue('token');
        if ($token !== Configuration::get('DATALAYER_TOKEN')) {
            header('HTTP/1.1 403 Forbidden');
            die('Invalid token');
        }

        $ga4Id = Configuration::get('DATALAYER_GA4_ID');
        $apiSecret = Configuration::get('DATALAYER_GA4_API');
        if (!$ga4Id || !$apiSecret) {
            die(json_encode(['error' => 'GA4 not configured']));
        }

        $pending = DataLayerOrder::getPendingOrders(50);
        $sent = 0;
        $errors = 0;

        foreach ($pending as $row) {
            $order = new Order((int) $row['id_order']);
            if (!Validate::isLoadedObject($order)) continue;

            $success = $this->sendToGA4($order, $ga4Id, $apiSecret);
            if ($success) {
                DataLayerOrder::markAsSent((int) $row['id_order'], 2);
                $sent++;
            } else {
                $errors++;
            }
            usleep(100000); // 100ms entre requests
        }

        die(json_encode([
            'processed' => count($pending),
            'sent'      => $sent,
            'errors'    => $errors,
        ]));
    }

    private function sendToGA4(Order $order, string $ga4Id, string $apiSecret): bool
    {
        $url = 'https://www.google-analytics.com/mp/collect?'
             . 'measurement_id=' . urlencode($ga4Id)
             . '&api_secret=' . urlencode($apiSecret);

        $payload = [
            'client_id' => 'server_cron_' . $order->id,
            'events'    => [[
                'name'   => 'purchase',
                'params' => [
                    'transaction_id' => $order->reference,
                    'value'          => (float) $order->total_paid_tax_incl,
                    'currency'       => Currency::getIsoCodeById((int) $order->id_currency),
                ],
            ]],
        ];

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode($payload),
            CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
            CURLOPT_TIMEOUT        => 5,
        ]);
        curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        return ($code >= 200 && $code < 300);
    }
}

// Cron: wget -q -O /dev/null 'https://tutienda.com/module/datalayertagmanager/send?token=XXX'

#Formulario de configuracion con tabs

getContent() + renderForm() con multiples tabs
php
public function getContent()
{
    $html = '';
    if (Tools::isSubmit('submitMyModule')) {
        $this->postProcess();
        $html .= $this->displayConfirmation($this->trans('Settings updated.'));
    }
    return $html . $this->renderForm();
}

protected function renderForm()
{
    $helper = new HelperForm();
    $helper->module = $this;
    $helper->token = Tools::getAdminTokenLite('AdminModules');
    $helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name;
    $helper->submit_action = 'submitMyModule';
    $helper->default_form_language = $this->context->language->id;

    // Cargar valores actuales
    $helper->fields_value = [
        'GTM_ID'     => Configuration::get('DATALAYER_GTM_ID'),
        'GA4_ID'     => Configuration::get('DATALAYER_GA4_ID'),
        'SEND_METHOD'=> Configuration::get('DATALAYER_SEND_METHOD'),
        'FB_PIXEL'   => Configuration::get('DATALAYER_FB_PIXEL'),
        'COOKIE_ACTIVE' => Configuration::get('DATALAYER_COOKIE_ACTIVE'),
    ];

    // Definir formulario con tabs
    return $helper->generateForm([
        // Tab 1: Google
        ['form' => [
            'legend' => ['title' => 'Google Tag Manager & GA4', 'icon' => 'icon-google'],
            'input'  => [
                ['type' => 'text', 'label' => 'GTM Container ID', 'name' => 'GTM_ID',
                 'desc' => 'Formato: GTM-XXXXXXX', 'size' => 20],
                ['type' => 'text', 'label' => 'GA4 Measurement ID', 'name' => 'GA4_ID',
                 'desc' => 'Formato: G-XXXXXXXXXX'],
                ['type' => 'select', 'label' => 'Metodo de envio', 'name' => 'SEND_METHOD',
                 'options' => [
                    'query' => [
                        ['id' => '1', 'name' => 'JavaScript (DataLayer push)'],
                        ['id' => '2', 'name' => 'Server-side (Cron + Measurement Protocol)'],
                    ],
                    'id' => 'id', 'name' => 'name',
                ]],
            ],
            'submit' => ['title' => 'Guardar'],
        ]],
        // Tab 2: Facebook
        ['form' => [
            'legend' => ['title' => 'Facebook / Meta Pixel', 'icon' => 'icon-facebook'],
            'input'  => [
                ['type' => 'text', 'label' => 'Pixel ID', 'name' => 'FB_PIXEL'],
            ],
            'submit' => ['title' => 'Guardar'],
        ]],
        // Tab 3: Cookies
        ['form' => [
            'legend' => ['title' => 'GDPR / Cookies', 'icon' => 'icon-lock'],
            'input'  => [
                ['type' => 'switch', 'label' => 'Activar popup cookies', 'name' => 'COOKIE_ACTIVE',
                 'is_bool' => true, 'values' => [
                    ['id' => 'active_on', 'value' => 1, 'label' => 'Si'],
                    ['id' => 'active_off', 'value' => 0, 'label' => 'No'],
                ]],
            ],
            'submit' => ['title' => 'Guardar'],
        ]],
    ]);
}

#Scripts de upgrade (15+ versiones)

Un modulo de larga vida tiene muchas versiones. Cada upgrade script se ejecuta automaticamente al actualizar. Ejemplo de como gestionar 15+ upgrades:

upgrade/upgrade-2.1.4.php
php
<?php
if (!defined('_PS_VERSION_')) { exit; }

function upgrade_module_2_1_4($module)
{
    // Añadir nueva columna
    Db::getInstance()->execute('
        ALTER TABLE `' . _DB_PREFIX_ . 'datalayer_orders`
        ADD COLUMN IF NOT EXISTS `sendgv4` TINYINT(1) NOT NULL DEFAULT 0
    ');

    // Registrar nuevos hooks
    $module->registerHook('displayBeforeBodyClosingTag');
    $module->registerHook('actionOrderStatusPostUpdate');

    // Nueva configuracion
    Configuration::updateValue('DATALAYER_SEND_GA4', 1);

    return true;
}
upgrade/upgrade-3.2.3.php — migracion de datos
php
<?php
if (!defined('_PS_VERSION_')) { exit; }

function upgrade_module_3_2_3($module)
{
    // Migrar configuracion antigua a nueva
    $oldValue = Configuration::get('DATALAYERTAGMANAGER_IDTAGMANAGER');
    if ($oldValue) {
        Configuration::updateValue('DATALAYER_GTM_ID', $oldValue);
    }

    // Limpiar configuraciones obsoletas
    $obsolete = ['DATALAYER_OLD_SETTING', 'DATALAYER_DEPRECATED_KEY'];
    foreach ($obsolete as $key) {
        Configuration::deleteByName($key);
    }

    return true;
}
💡
Patron de upgrades

Nombra los scripts como upgrade-X.Y.Z.php. PrestaShop los ejecuta automaticamente en orden de version cuando el modulo se actualiza. Cada script debe devolver true para indicar exito.

Descargar en Markdown Pensado para pegar en ChatGPT, Claude u otra IA. Incluye solo el contenido de esta pagina.