📊 DataLayer + GTM — modulo de analytics 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):
<?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
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
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
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
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
<?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
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:
<?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;
}
<?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;
}
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.