ProcessWire Analytics Implementation Guide | OpsBlu Docs

ProcessWire Analytics Implementation Guide

Install tracking scripts, build data layers, and debug analytics on ProcessWire using template files, the $page API, hooks, and modules.

Analytics Architecture on ProcessWire

ProcessWire uses PHP template files that map directly to admin-defined templates. Each template file has full access to the ProcessWire API, making analytics implementation a matter of writing PHP in the right template files:

  • Template files (site/templates/) contain the HTML output and have full access to $page, $pages, $config, and all PW API variables
  • _init.php executes before every template file (prepend file) -- ideal for data layer setup
  • _main.php provides a shared layout wrapper (append file) -- ideal for global script injection
  • Modules extend functionality with hooks into page render, save, and load events
  • $config->scripts and $config->styles arrays allow templates to register assets that get rendered in the layout

The $page object exposes all fields for the current page, and wire('pages') provides query access to any page in the site tree.


Installing Tracking Scripts

ProcessWire's prepend/append file system (_init.php / _main.php) provides a single layout wrapper. Add tracking code in _main.php:

<!-- site/templates/_main.php -->
<!DOCTYPE html>
<html>
<head>
  <title><?= $page->title ?></title>
  <?php foreach($config->styles as $file) echo "<link rel='stylesheet' href='$file'>"; ?>

  <!-- Analytics -->
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', 'G-XXXXXXX');
  </script>

  <?= $dataLayerScript ?? '' ?>
</head>
<body>
  <?= $content ?>

  <?php foreach($config->scripts as $file) echo "<script src='$file'></script>"; ?>
</body>
</html>

Method 2: _init.php for Conditional Loading

Use _init.php to control when analytics loads based on template or page properties:

<?php
// site/templates/_init.php

$analyticsEnabled = true;

// Disable on admin-only templates
if ($page->template->name === 'admin' || $page->template->hasTag('no-analytics')) {
    $analyticsEnabled = false;
}

// Disable for logged-in editors
if ($user->hasRole('editor') || $user->hasRole('superuser')) {
    $analyticsEnabled = false;
}

Then in _main.php:

<?php if ($analyticsEnabled): ?>
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
  <!-- ... -->
<?php endif; ?>

Method 3: Module-Based Injection

Create a module that hooks into page render to inject scripts:

<?php
// site/modules/AnalyticsInjector/AnalyticsInjector.module

class AnalyticsInjector extends WireData implements Module {

    public static function getModuleInfo() {
        return [
            'title' => 'Analytics Injector',
            'version' => 1,
            'autoload' => true,
        ];
    }

    public function init() {
        $this->addHookAfter('Page::render', $this, 'injectAnalytics');
    }

    public function injectAnalytics(HookEvent $event) {
        $page = $event->object;
        if ($page->template->name === 'admin') return;

        $tracking = '<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>';
        $event->return = str_replace('</head>', $tracking . '</head>', $event->return);
    }
}

Data Layer Implementation

Building Data Layer in _init.php

Construct the data layer object in _init.php so it's available in every template:

<?php
// site/templates/_init.php

$dataLayer = [
    'page_id' => $page->id,
    'page_title' => $page->title,
    'page_template' => $page->template->name,
    'page_path' => $page->url,
    'parent_id' => $page->parent->id,
    'parent_title' => $page->parent->title,
    'created' => date('Y-m-d', $page->created),
    'modified' => date('Y-m-d', $page->modified),
    'depth' => count($page->parents),
];

// Add template-specific fields
if ($page->template->name === 'product') {
    $dataLayer['product_name'] = $page->title;
    $dataLayer['product_sku'] = $page->product_sku;
    $dataLayer['product_price'] = (float) $page->product_price;
    $dataLayer['product_category'] = $page->product_category->title ?? '';
}

if ($page->template->name === 'blog-post') {
    $dataLayer['author'] = $page->author->title ?? '';
    $dataLayer['publish_date'] = date('Y-m-d', $page->published);
    $dataLayer['word_count'] = str_word_count(strip_tags($page->body));
    $dataLayer['tags'] = $page->tags->explode('title');
}

$dataLayerScript = '<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(' . json_encode($dataLayer, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');
</script>';

Use wire('pages') to pull site-wide context into the data layer:

<?php
// Count siblings in same section
$siblingCount = $page->parent->children->count;

// Get breadcrumb path
$breadcrumb = $page->parents->explode('title');
$breadcrumb[] = $page->title;

$dataLayer['breadcrumb'] = implode(' > ', $breadcrumb);
$dataLayer['section_page_count'] = $siblingCount;

// Total published products (for e-commerce context)
$dataLayer['total_products'] = wire('pages')->count('template=product, status=published');

User Session Data

<?php
if ($user->isLoggedin()) {
    $dataLayer['user_logged_in'] = true;
    $dataLayer['user_id'] = $user->id;
    $dataLayer['user_roles'] = $user->roles->explode('name');
} else {
    $dataLayer['user_logged_in'] = false;
}

Common Issues

Output Caching Conflicts

ProcessWire's $config->templateCompile and ProCache module can cache rendered output. Data layer values that change per-user (login state, session data) will serve stale values from cache.

Solutions:

  • Disable ProCache on pages with user-specific data layer values
  • Use JavaScript-based user detection instead of server-side:
// Client-side user detection (cache-safe)
fetch('/api/user-status/')
  .then(r => r.json())
  .then(data => {
    window.dataLayer.push({
      'user_logged_in': data.loggedIn,
      'user_id': data.userId
    });
  });

With a corresponding template file:

<?php
// site/templates/api/user-status.php
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode([
    'loggedIn' => $user->isLoggedin(),
    'userId' => $user->isLoggedin() ? $user->id : null,
]);

Multi-Language Sites

ProcessWire's multi-language support changes field values per language. Ensure your data layer reflects the active language:

<?php
$dataLayer['language'] = $user->language->name;
$dataLayer['page_title'] = $page->getLanguageValue($user->language, 'title');

URL Segments and Pagination

Pages with URL segments enabled ($config->urlSegments) can serve different content at the same base URL. Track the full URL including segments:

<?php
$dataLayer['page_path'] = $page->url;
$dataLayer['url_segments'] = $input->urlSegments();
$dataLayer['pagination_page'] = $input->pageNum;

// Full request path
$dataLayer['request_path'] = $input->url;

Hook Execution Order

When multiple modules hook Page::render, execution order depends on module load order. If your analytics module conflicts with a caching or minification module, set priority:

$this->addHookAfter('Page::render', $this, 'injectAnalytics', ['priority' => 200]);

Higher priority numbers execute later. Default is 100.


Platform-Specific Considerations

Field Types and Data Layer Values: ProcessWire field types return different PHP types. Handle them correctly:

  • FieldtypePage (page reference) returns a Page or PageArray object -- use ->title or ->explode('title')
  • FieldtypeRepeater returns a RepeaterPageArray -- iterate with foreach
  • FieldtypeOptions returns a SelectableOptionArray -- use ->explode('title') or ->explode('value')
  • FieldtypeImage returns Pageimages -- not useful for data layers directly

Repeater and RepeaterMatrix Fields: For repeating content blocks, extract structured data:

<?php
if ($page->hasField('content_blocks')) {
    $blocks = [];
    foreach ($page->content_blocks as $block) {
        $blocks[] = $block->type; // RepeaterMatrix type name
    }
    $dataLayer['content_block_types'] = $blocks;
    $dataLayer['content_block_count'] = count($blocks);
}

API-Generated Pages: ProcessWire can serve JSON API responses via template files. For headless/API templates, skip HTML analytics injection and rely on client-side tracking in the consuming application.

$config->scripts Array: Templates can register JavaScript files to be included in the layout:

<?php
// In a template file
$config->scripts->add($config->urls->templates . 'scripts/product-tracking.js');

These render in _main.php via the foreach($config->scripts ...) loop, keeping template-specific tracking scripts organized.

Admin Template Exclusion: Always check $page->template->name !== 'admin' in hooks and _init.php to avoid injecting analytics into the ProcessWire admin interface.