ProcessWire Custom Template Development
A template in ProcessWire is a PHP file in /site/templates/ with the same name as the template in database. The file receives $page object with current page data and full access to $pages, $config, $user, and rest of the API. No template engine by default — pure PHP, though Twig can be added via TemplateEngineTwig module.
Template Architecture: Separation Options
Option 1: _init.php + _main.php
The most common approach. ProcessWire automatically includes _init.php before each template and _main.php after, if "Prepend/Append template file" option is enabled in template settings.
// _init.php — global variables, helpers
$baseUrl = $config->urls->root;
$homePage = $pages->get("/");
$mainNav = $pages->find("parent=/,template!=error404,sort=sort");
function formatDate(int $ts): string {
return date("d.m.Y", $ts);
}
// _main.php — layout wrapper
?><!DOCTYPE html>
<html lang="<?= $user->language->name ?>">
<head>
<meta charset="utf-8">
<title><?= $page->title ?> | <?= $homePage->title ?></title>
<link rel="stylesheet" href="<?= $config->urls->templates ?>css/main.css">
</head>
<body>
<?php include("partials/nav.php"); ?>
<main><?= $content ?></main>
<?php include("partials/footer.php"); ?>
</body>
</html>
In page template, $content accumulates via output buffering:
// services.php — ProcessWire automatically buffers output
$items = $pages->find("template=service, parent={$page}, sort=sort");
foreach ($items as $item) {
echo "<article>";
echo "<h2><a href='{$item->url}'>{$item->title}</a></h2>";
echo "<p>{$item->summary}</p>";
echo "</article>";
}
Option 2: Controller + View
For complex templates, logic is extracted to separate file:
templates/
controllers/
services.php # logic only, no echo
views/
services.view.php # markup only
services.php # entry point, connects controller+view
// templates/services.php
require __DIR__ . '/controllers/services.php';
extract($viewData); // variables from controller
require __DIR__ . '/views/services.view.php';
// templates/controllers/services.php
$viewData = [
'items' => $pages->find("template=service, sort=sort, limit=12"),
'categories' => $pages->find("template=service-category, sort=sort"),
'pagination' => $modules->get("MarkupPagerNav"),
];
Working with Images
ProcessWire handles images on-the-fly via size() method:
// Main page image
$img = $page->images->first();
if ($img) {
// Resize with cropping 800×600
$thumb = $img->size(800, 600, ['cropping' => 'center', 'quality' => 85]);
echo "<img src='{$thumb->url}' alt='{$img->description}' loading='lazy'>";
// WebP via suffix option
$webp = $img->size(800, 600, ['suffix' => 'webp', 'webpAdd' => true]);
}
Variant sizes are cached in /site/assets/files/{id}/. Repeat requests return cached file.
Reusable Partial Templates
// partials/card.php — receives $item from include
?>
<div class="card">
<?php if ($item->image): ?>
<img src="<?= $item->image->size(400,300)->url ?>" alt="<?= $item->title ?>">
<?php endif; ?>
<h3><?= $item->title ?></h3>
<p><?= $item->summary ?></p>
<a href="<?= $item->url ?>">Read more</a>
</div>
// Usage in services.php
foreach ($items as $item) {
include("./partials/card.php");
}
Meta Data and SEO
SEO fields usually added at template level via separate fieldset:
// In _main.php
$metaTitle = $page->meta_title ?: $page->title;
$metaDescription = $page->meta_description ?: $page->summary;
$ogImage = $page->og_image ? $page->og_image->size(1200, 630)->httpUrl : '';
?>
<title><?= htmlspecialchars($metaTitle) ?></title>
<meta name="description" content="<?= htmlspecialchars($metaDescription) ?>">
<meta property="og:image" content="<?= $ogImage ?>">
Custom 404 and Redirects
// templates/basic-page.php
// Redirect old URL
if ($page->redirect_url) {
$session->redirect($page->redirect_url, 301);
}
// Force 404
if (!$user->isLoggedin() && $page->requires_login) {
throw new Wire404Exception();
}
Performance: Lazy Loading Relations
By default, FieldtypePage loads related objects on access. For large lists use $pages->findMany() — streaming processing without loading everything in memory:
// findMany() for large sets (1000+ pages)
foreach ($pages->findMany("template=product, sort=title") as $product) {
echo $product->title . "\n";
// ProcessWire automatically frees memory
}
Template Development Timeline
| Task | Estimate |
|---|---|
| Basic template (layout + nav + footer) | 4–8 h |
| List template with pagination and filter | 8–16 h |
| Detail page with relations | 4–10 h |
| System of 10+ content type templates | 3–6 days |
| Twig integration + component approach | 2–4 days |







