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.phpexecutes before every template file (prepend file) -- ideal for data layer setup_main.phpprovides a shared layout wrapper (append file) -- ideal for global script injection- Modules extend functionality with hooks into page render, save, and load events
$config->scriptsand$config->stylesarrays 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
Method 1: Shared Layout File (Recommended)
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>';
Querying Related Pages for Context
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 aPageorPageArrayobject -- use->titleor->explode('title')FieldtypeRepeaterreturns aRepeaterPageArray-- iterate withforeachFieldtypeOptionsreturns aSelectableOptionArray-- use->explode('title')or->explode('value')FieldtypeImagereturnsPageimages-- 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.