Rendering (Pages Controllers & Templates)
Preparing Data (The Controller)
Unlike heavy MVC frameworks, Pragma CMS uses a pragmatic "Page Controller" pattern. A Page Controller is a single PHP file responsible for the entire lifecycle of a specific route (e.g., your blog index).
A Page Controller is a single PHP file mapped to a route (e.g. /blog).
It is responsible for orchestrating the entire request lifecycle.
It does not represent the page itself, but controls how the page is built and rendered.
Responsibilities of a Page Controller
- Fetches data from Managers (business/domain logic)
- Prepares and structures data in the
$pageobject - Configures page-level settings (SEO, breadcrumbs, layout)
- Selects and renders templates (views)
- Returns the final HTML response
This approach avoids unnecessary abstraction layers and provides a clear, linear execution flow that is easy to understand and maintain.
To pass data to the template (the view), you use the $page object.
Example: A Blog List Controller (pages/blog/index.php)
<?php
// Retrieve the current route handle from the page context (set by router.php)
$routeHandle = $page->currentRoute["handle"];
// Fetch the page configuration/data associated with this route
$pageData = PageManager::getPageDataForRoute($routeHandle);
// If no page is found, display a 404 error and stop execution
if (!$pageData) {
displayError(getTranslation("The page does not exist"), 404);
exit;
}
// Get the content type handle associated with this page (e.g. "article", "recipe")
$contentTypeHandle = $pageData["page_content_type_handle"];
// Retrieve the full content type configuration (fields, taxonomies, etc.)
$contentType = ContentTypeManager::get($contentTypeHandle);
// If the content type does not exist, throw a server error
if (!$contentType) {
displayError("Content Type '{$contentTypeHandle}' not found.", 500);
exit;
}
// Get the current language ID from session
$langId = $_SESSION["lang_id"];
// Handle only GET requests (page display & filtering)
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// ---------------------------------------------------------
// 1. Load available taxonomies for filtering
// ---------------------------------------------------------
$taxonomies = [];
// Loop through all taxonomies defined for this content type
foreach ($contentType["taxonomies"] ?? [] as $taxonomyType) {
// Retrieve taxonomy metadata (translated name, slug, etc.)
$taxonomyData = TaxonomyManager::getTaxonomyByType($taxonomyType, $langId);
if ($taxonomyData) {
$taxonomies[] = $taxonomyData;
}
}
// ---------------------------------------------------------
// 2. Retrieve taxonomy filters from URL
// Example: ?terms[]=1&terms[]=5
// ---------------------------------------------------------
$termIds = array_filter($_GET['terms'] ?? [], 'is_numeric');
// Options that will be passed to the query builder
$options = [];
if (!empty($termIds)) {
// Recursive query that retrieves all descendants (children, grandchildren, etc.)
// for a set of term IDs, regardless of taxonomy structure.
$allTermIds = TaxonomyTermManager::getTermIdsWithAllDescendants($termIds);
if ($allTermIds) {
// Build dynamic JOIN + parameters for filtering entries
$options = [
'joins' => [
// Join the term_relationships table to filter entries
"INNER JOIN term_relationships tr
ON e.entry_id = tr.object_id
AND tr.object_type = '{$contentTypeHandle}'"
],
'params' => [
// Pass all relevant term IDs for filtering
'terms' => $allTermIds
]
];
}
}
// ---------------------------------------------------------
// 3. Retrieve entries (automatically hydrated with taxonomies)
// ---------------------------------------------------------
$articles = EntryManager::getEntries("article", $langId, $options);
// ---------------------------------------------------------
// 4. Mark selected terms in taxonomies (for UI state)
// ---------------------------------------------------------
// Retrieve full data for selected terms
$selectedTerms = !empty($termIds)
? TaxonomyTermManager::getTermsByIds($termIds, $langId)
: [];
// Attach selected terms to their corresponding taxonomy
foreach ($taxonomies as &$taxonomy) {
$taxonomy['selected_terms'] = array_filter(
$selectedTerms,
fn($term) => $term['taxonomy_type'] === $taxonomy['type']
);
}
unset($taxonomy); // break reference
// ---------------------------------------------------------
// 5. Configure page rendering
// ---------------------------------------------------------
$page->viewData = [
// Page title (fallback if not defined)
'title' => $pageData["title"] ?? getTranslation("articles"),
// Filtered entries
'articles' => $articles,
// Taxonomies with selected state
'taxonomies' => $taxonomies,
// Boolean used in UI to indicate active filters
'has_active_filters' => !empty($termIds)
];
// SEO metadata
$page->metaTitle = $pageData["meta_title"] ?? $page->viewData["title"];
$page->metaDescription = $pageData["meta_description"] ?? "";
// Load custom fields attached to the page entry
$page->entry["fields"] = EntryManager::getEntryFields($pageData["page_id"], $langId);
// Build layout components
$page->header = renderHeader($page);
$page->footer = renderFooter($page);
// Render the main template for this page
$page->main = render_template($pageData["template_path"], $page);
// Render final layout (base template)
echo render_template("base.php", $page);
}
Rendering Data (The View)
Templates are responsible only for displaying data.
htmlspecialchars().METHOD A: Native PHP
<?=
// Render breadcrumb navigation for current page context
BreadcrumbManager::renderBreadcrumbs();
?>
<section class="cms-hero-container">
<div class="hero-title">
<div class="container">
<?php // Render main page title from entry fields ?>
<h1><?= htmlspecialchars($page->entry["fields"]["hero-title"]); ?></h1>
<?php // Render page subtitle from entry fields ?>
<h2><?= htmlspecialchars($page->entry["fields"]["hero-subtitle"]); ?></h2>
</div>
</div>
<div class="hero-image">
<?php // Render hero image using media helper (optimized size: xlarge) ?>
<?php renderImage($page->entry["fields"]["hero-image"]["id"], 'xlarge'); ?>
<?php // Visual blur overlay for hero image styling ?>
<div class="blur"></div>
</div>
</section>
<section>
<div class="container">
<?php // Display filter form only if taxonomies are available ?>
<?php if(!empty($page->viewData['taxonomies'])): ?>
<form method="GET" id="articles-filter-form" class="filter-form">
<?php // Loop through available taxonomies for filtering ?>
<?php foreach($page->viewData['taxonomies'] as $taxonomy): ?>
<div class="flex-column items-start gap-s">
<?php // Display taxonomy label ?>
<label class="form-label">
<?= htmlspecialchars($taxonomy["name"]); ?>
</label>
<?php // Render reusable relation field (taxonomy terms selector) ?>
<?=
UI::renderRelationField(
baseInputName:'terms',
currentValue:$taxonomy['selected_terms'] ?? [],
options:[
// API endpoint for loading taxonomy terms
'api' => 'api/taxonomies/' . $taxonomy['type'] . '/terms',
],
isMultiple:true,
langId:$_SESSION['lang_id'] ?? null,
idKey:'term_id',
nameKey:'name',
listType:'tree-dropdown'
);
?>
</div>
<?php endforeach; ?>
<?php // Submit filter form ?>
<button class="self-center"><?= getTranslation("Search"); ?></button>
</form>
<?php endif; ?>
<?php // Display articles grid if results exist ?>
<?php if (!empty($page->viewData['articles'])) : ?>
<div class="grid-container">
<?php // Loop through articles collection ?>
<?php foreach($page->viewData['articles'] as $article) : ?>
<div class="cms-card grid-item-3-col">
<div class="card-image">
<?php // Link to article detail page ?>
<a href="<?= getLink("front.blog.show", ["slug" => htmlspecialchars($article["slug"])]); ?>">
<?php // Render article thumbnail image ?>
<?php renderImage($article["image"]['id'], attributes:["class" => "image-4-3"]); ?>
</a>
</div>
<div class="card-content">
<div class="card-header">
<?php // Render article taxonomy terms if available ?>
<?php if (!empty($article['terms'])): ?>
<?php foreach ($article["terms"] as $term) : ?>
<?php // Link to filtered taxonomy term page ?>
<a href="<?= getLink("front.taxonomies.terms.show", ["taxonomy_slug" => htmlspecialchars($term["taxonomy_slug"]), "term_slug" => htmlspecialchars($term["term_slug"])]); ?>" class="term-link">
<?= htmlspecialchars($term["name"]) ?>
</a>
<?php endforeach; ?>
<?php endif; ?>
<?php // Article title linking to detail page ?>
<h3>
<a href="<?= getLink("front.blog.show", ["slug" => htmlspecialchars($article["slug"])]); ?>">
<?= htmlspecialchars($article["title"]); ?>
</a>
</h3>
</div>
<?php // Article short description preview ?>
<div class="description">
<?= htmlspecialchars(truncateText($article["description"], 100)); ?>
</div>
<?php // Article publication date formatted for display and machine readability ?>
<time datetime="<?= date('Y-m-d\TH:i:s', strtotime($article['created_at'])); ?>">
<?= date('d/m/Y', strtotime($article['created_at'])); ?>
</time>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else : ?>
<?php // Empty state depending on filter context ?>
<?php if ($page->viewData['has_active_filters']) : ?>
<p><?= getTranslation("No result"); ?></p>
<?php else : ?>
<p><?= getTranslation("No articles found yet"); ?>.</p>
<?php endif; ?>
<?php endif; ?>
<?php // Show reset filter button only when filters are active ?>
<?php if ($page->viewData['has_active_filters']) : ?>
<a href="<?= getLink("front.blog.index") ?>" class="button-primary mt-s self-start">
<?= getTranslation("Reset search"); ?>
</a>
<?php endif; ?>
</div>
</section>
METHOD B: TWIG
{# Render breadcrumb navigation for current page context #}
{{ BreadcrumbManager.renderBreadcrumbs() }}
<section class="cms-hero-container">
<div class="hero-title">
<div class="container">
{# Render main page title from entry fields #}
<h1>{{ page.entry.fields['hero-title']|e }}</h1>
{# Render page subtitle from entry fields #}
<h2>{{ page.entry.fields['hero-subtitle']|e }}</h2>
</div>
</div>
<div class="hero-image">
{# Render hero image using media helper (optimized size: xlarge) #}
{{ renderImage(page.entry.fields['hero-image'].id, 'xlarge') }}
{# Visual blur overlay for hero image styling #}
<div class="blur"></div>
</div>
</section>
<section>
<div class="container">
{# Display filter form only if taxonomies are available #}
{% if page.viewData.taxonomies is not empty %}
<form method="GET" id="articles-filter-form" class="filter-form">
{# Loop through available taxonomies for filtering #}
{% for taxonomy in page.viewData.taxonomies %}
<div class="flex-column items-start gap-s">
{# Display taxonomy label #}
<label class="form-label">
{{ taxonomy.name|e }}
</label>
{# Render reusable relation field (taxonomy terms selector) #}
{{
UI.renderRelationField(
'terms',
taxonomy.selected_terms ?? [],
{
api: 'api/taxonomies/' ~ taxonomy.type ~ '/terms'
},
true,
session.lang_id ?? null,
'term_id',
'name',
'tree-dropdown'
)
}}
</div>
{% endfor %}
{# Submit filter form #}
<button class="self-center">
{{ 'Search'|trans }}
</button>
</form>
{% endif %}
{# Display articles grid if results exist #}
{% if page.viewData.articles is not empty %}
<div class="grid-container">
{# Loop through articles collection #}
{% for article in page.viewData.articles %}
<div class="cms-card grid-item-3-col">
<div class="card-image">
{# Link to article detail page #}
<a href="{{ getLink('front.blog.show', { slug: article.slug|e }) }}">
{# Render article thumbnail image #}
{{ renderImage(article.image.id, { class: 'image-4-3' }) }}
</a>
</div>
<div class="card-content">
<div class="card-header">
{# Render article taxonomy terms if available #}
{% if article.terms is not empty %}
{% for term in article.terms %}
{# Link to filtered taxonomy term page #}
<a
href="{{ getLink('front.taxonomies.terms.show', {
taxonomy_slug: term.taxonomy_slug|e,
term_slug: term.term_slug|e
}) }}"
class="term-link"
>
{{ term.name|e }}
</a>
{% endfor %}
{% endif %}
{# Article title linking to detail page #}
<h3>
<a href="{{ getLink('front.blog.show', { slug: article.slug|e }) }}">
{{ article.title|e }}
</a>
</h3>
</div>
{# Article short description preview #}
<div class="description">
{{ article.description|truncateText(100)|e }}
</div>
{# Article publication date #}
<time datetime="{{ article.created_at|date('c') }}">
{{ article.created_at|date('d/m/Y') }}
</time>
</div>
</div>
{% endfor %}
</div>
{% else %}
{# Empty state depending on filter context #}
{% if page.viewData.has_active_filters %}
<p>{{ 'No result'|trans }}</p>
{% else %}
<p>{{ 'No articles found yet'|trans }}.</p>
{% endif %}
{% endif %}
{# Show reset filter button only when filters are active #}
{% if page.viewData.has_active_filters %}
<a href="{{ getLink('front.blog.index') }}" class="button-primary mt-s self-start">
{{ 'Reset search'|trans }}
</a>
{% endif %}
</div>
</section>
Note: In Twig, the |e or |escape filter is highly recommended to secure HTML output.
Template Resolution
To render a template, use render_template("filename.php", $page).
The CMS will use find_template_file() to locate the correct file based on the fallback logic explained earlier. The overarching HTML structure (like <head> and <body>) is typically handled by base.php, which wraps the output of your specific templates.
Taxonomy & Term Hierarchy
Pragma CMS uses a "most-specific-first" logic for taxonomies. This allows you to override designs at the global, taxonomy, or even individual term level.
For Taxonomy Index (List of all terms, e.g., /blog-categories):
taxonomy/{taxonomy_handle}.phptaxonomy/list.php(Generic fallback)
For a Specific Term (List of entries, e.g., /blog-categories/technology):
The system checks for the following files in your theme in order:
taxonomy/{taxonomy_handle}/{term_slug}.php— Example:taxonomy/blog_category/technology.phptaxonomy/{taxonomy_handle}/single.php— Example:taxonomy/blog_category/single.phptaxonomy/single.php— The universal fallback