Why ProcessWire's Page-as-Entity Model Beats Custom Tables for Content Relationships
ProcessWire models all content as Pages with reusable Fields, making relationships first-class and editable in the admin without custom tables. Selectors provide a readable query language that returns Page objects, trading raw SQL control for template readability and rapid iteration.
08 Oct 2025, 14:43 UTC

The problem: relational content without the relational headache
You're building a site where articles link to authors, authors belong to departments, and departments have featured projects. In a traditional stack you'd design junction tables, write JOINs, and maintain an ORM layer. Every schema change means migrations, model updates, and template adjustments. ProcessWire takes a different bet: model everything as Pages, relate them with Page reference fields, and query with Selectors. The result is a content graph you can edit in the admin and traverse in templates without writing SQL.
Pages, fields, and references as first-class citizens
In ProcessWire every content item is a Page. Fields—text, images, repeaters, or references to other Pages—are attached to templates in the admin. A Page reference field stores the target Page's ID, but in your template $page->author returns a Page object, not a raw ID. That object has its own fields and references, so $page->author->department->title just works. No join syntax, no lazy-loading configuration, no N+1 surprises because the selector engine fetches what you ask for.
Relationships stay editable in the admin. Editors drag-and-drop to reorder related items, search by title, or create new related Pages inline. The schema lives in one place: the field and template configuration. Your PHP templates read the same structure the admin writes.
Selectors: a readable query language for the content graph
ProcessWire's Selector syntax powers $pages->find() and $pages->get(). It reads like a sentence: template=article, author.department=marketing, published=1, sort=-date. Operators include =, !=, ^= (starts with), *= (contains), >=, <=, and field.subfield traversal. Results are PageArrays—iterable, countable, and sliceable with ->slice(0, 10) for pagination.
Under the hood the selector compiles to SQL, but you never see it. The engineering decision favors template readability over raw query control. For the 95% case—filtering, sorting, limiting by fields on related Pages—this trades a few milliseconds of abstraction overhead for massive maintenance savings.
Worked example: article listing with author department filter
Assume templates article, author, department. article has a Page reference field author (single). author has department (single). In site/templates/article-list.php:
<?php
namespace ProcessWire;
// Get department from URL segment or default
$deptName = $input->urlSegment1 ?: 'marketing';
$dept = $pages->get("template=department, name=$deptName");
if (!$dept->id) {
throw new Wire404Exception();
}
// Selector uses subfield traversal: author.department
$articles = $pages->find([
'template' => 'article',
'published' => 1,
'author.department' => $dept,
'sort' => '-published_date',
'limit' => 12,
]);
// Pagination
$pager = $articles->renderPager([
'nextItemLabel' => 'Next',
'prevItemLabel' => 'Prev',
'listMarkup' => "{out}",
'itemMarkup' => "{out}",
]);
?>
<h1>Articles in <?= $dept->title ?></h1>
<ul class='article-list'>
<?php foreach ($articles as $art): ?>
<li>
<a href='<?= $art->url ?>'><?= $art->title ?></a>
<span class='author'>By <?= $art->author->name ?></span>
<time><?= date('Y-m-d', $art->published_date) ?></time>
</li>
<?php endforeach; ?>
</ul>
<?= $pager ?>
Where to run: Place this file in site/templates/article-list.php and assign it to a Page using the article-list template. Requires admin access to create templates and fields; template file needs filesystem write permission for the site/templates directory.
Expected check: Visit the page URL with /marketing/ segment. You should see up to 12 published articles whose author's department is "marketing", sorted newest first. Pagination links appear if more exist.
Risk: If the author or department reference is empty on any article, $art->author->name throws "Trying to get property of non-object". Guard with if ($art->author->id) or use the nullsafe operator $art->author?->name (PHP 8+).
Trade-off: selector depth vs. query cost
Deep traversals like author.department.parent.title are convenient but each level adds a JOIN. On a site with 50k+ Pages, a selector touching three reference levels without pagination can exceed the default 30-second PHP timeout. The fix is simple: paginate (limit=20), add an indexed field for common filters (e.g., a hidden department_id on article populated via hook), or use $pages->findRaw() for read-only reporting where you only need specific columns.
ProFields modules (Repeater, PageTable, Combo) extend relational power but require separate licenses and increase admin complexity. Evaluate whether a Repeater matrix field replaces a separate template before committing.
Verify it yourself in five minutes
- In Admin > Setup > Templates, create
department,author,articletemplates (no file needed yet). - Add Page reference fields:
departmentonauthor(single),authoronarticle(single). - Create a few Pages of each type and link them.
- Create
site/templates/article-list.phpwith the example above. - Assign the template to a Page, visit it with a department URL segment.
If the list renders with correct relationships, the page-as-entity model is working as designed. No migrations, no ORM config, no raw SQL.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.