Template Writing Guide
This guide covers practical template patterns in Mini, from basic usage to real-world scenarios with multi-level layouts, reusable parts, and data handling.
Quick Start
Templates are PHP files in _views/. Render them from controllers:
$html = render('profile.php', ['user' => $user]);
// _views/profile.php
<h1><?= htmlspecialchars($user->name) ?></h1>
<p><?= htmlspecialchars($user->bio) ?></p>
Variables passed to render() are extracted into the template scope.
Template Resolution
When you call render('profile.php'), Mini searches for the template in this order:
- Application's
_views/- Your project's templates (checked first) - Composer packages'
_views/- Any package that registers withMini::$mini->paths->views - Mini framework's
views/- Built-in fallback templates
The first match wins. This means you can override any template by placing a file with the same path in your application's _views/ folder.
Reusable template packages: Composer packages can provide base layouts or reusable parts:
// In a composer package's bootstrap file
Mini::$mini->paths->views->add(__DIR__ . '/_views');
For example, a acme/bootstrap-layout package could provide _layout.php and common parts. Your application can use them directly or override specific templates as needed.
Template Inheritance
Templates can extend layouts using $this->extend(), define content with $this->block(), and layouts output that content with $this->show().
Child Template
// _views/about.php
<?php $this->extend(); ?>
<?php $this->block('title', 'About Us'); ?>
<?php $this->block('content'); ?>
<h1>About Us</h1>
<p>We build software.</p>
<?php $this->end(); ?>
Parent Layout
// _views/_layout.php
<!DOCTYPE html>
<html>
<head>
<title><?php $this->show('title', 'My Site'); ?></title>
<?php $this->show('head'); ?>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>
<?php $this->show('content'); ?>
</main>
<footer>
<?php $this->show('footer', '© ' . date('Y')); ?>
</footer>
</body>
</html>
The Four Helpers
| Method | Purpose |
|---|---|
$this->extend($layout) |
Extend a parent layout. No argument uses the default from _viewstart.php |
$this->block($name, $value) |
Define a block. Inline if value given, or start buffering |
$this->end() |
End a buffered block |
$this->show($name, $default) |
Output a block in parent templates. Default is only evaluated if block is missing |
Call order: extend() must be called once, before defining any blocks.
Inline vs Buffered blocks:
// Inline - for simple values
<?php $this->block('title', 'Contact Us'); ?>
// Buffered - for HTML content
<?php $this->block('content'); ?>
<h1>Contact</h1>
<form>...</form>
<?php $this->end(); ?>
Block override behavior: If you define the same block twice, the last definition wins. Inline blocks overwrite buffered ones and vice versa.
ViewStart: Setting Up Templates
_viewstart.php files run before templates render. Use them to set defaults and bring in common variables.
Root ViewStart
// _views/_viewstart.php
<?php
// Default layout for all templates
$layout = '_layout.php';
// Common helpers available in all templates
$h = fn($s) => htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8'); // HTML text
$a = fn($s) => htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8'); // HTML attributes (same, but semantic)
$fmt = mini\fmt();
$t = fn($text, $vars = []) => mini\t($text, $vars);
// Current user (if authenticated)
$currentUser = mini\auth()->user();
Now every template has access to $h(), $a(), $fmt, $t(), and $currentUser without passing them explicitly.
Section-Specific ViewStart
Create _viewstart.php in subdirectories to customize sections:
// _views/admin/_viewstart.php
<?php
$layout = 'admin/_layout.php'; // Admin section uses different layout
$adminSection = true;
// Require authentication for all admin templates
if (!$currentUser || !$currentUser->isAdmin()) {
mini\redirect('/login');
}
Execution order for _views/admin/users/list.php:
_views/_viewstart.php(sets global defaults)_views/admin/_viewstart.php(sets admin defaults, overrides$layout)_views/admin/users/list.php(your template)
Real-World Layout Structure
A typical site has a base layout, section layouts, and page templates.
Directory Structure
_views/
├── _viewstart.php # Global: $layout, $h, $fmt, $currentUser
├── _layout.php # Base HTML structure
├── home.php # Homepage
├── about.php # Static page
├── admin/
│ ├── _viewstart.php # Admin: different layout, auth check
│ ├── _layout.php # Admin layout with sidebar
│ ├── dashboard.php
│ └── users/
│ ├── list.php
│ └── edit.php
├── blog/
│ ├── _viewstart.php # Blog: adds $categories
│ ├── _layout.php # Blog layout with sidebar
│ ├── index.php
│ └── post.php
└── parts/
├── user-badge.php # Reusable components
├── pagination.php
└── flash-messages.php
Base Layout
// _views/_layout.php
<!DOCTYPE html>
<html lang="<?php $this->show('lang', 'en'); ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php $this->show('title', 'My Site'); ?></title>
<link rel="stylesheet" href="/css/app.css">
<?php $this->show('head'); ?>
</head>
<body class="<?php $this->show('body-class'); ?>">
<header>
<nav class="main-nav">
<a href="/" class="logo">MySite</a>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/about">About</a></li>
</ul>
<?php if ($currentUser): ?>
<span>Welcome, <?= $h($currentUser->name) ?></span>
<?php else: ?>
<a href="/login">Login</a>
<?php endif; ?>
</nav>
</header>
<?= render('parts/flash-messages.php') ?>
<main>
<?php $this->show('content'); ?>
</main>
<footer>
<?php $this->show('footer', '<p>© ' . date('Y') . ' My Site</p>'); ?>
</footer>
<script src="/js/app.js"></script>
<?php $this->show('scripts'); ?>
</body>
</html>
Section Layout (Blog)
The blog layout extends the base layout and adds a sidebar:
// _views/blog/_layout.php
<?php $this->extend('_layout.php'); ?>
<?php $this->block('body-class', 'blog-section'); ?>
<?php $this->block('content'); ?>
<div class="blog-container">
<div class="blog-content">
<?php $this->show('blog-content'); ?>
</div>
<aside class="blog-sidebar">
<h3>Categories</h3>
<ul>
<?php foreach ($categories as $cat): ?>
<li><a href="/blog?category=<?= $cat->id ?>"><?= $h($cat->name) ?></a></li>
<?php endforeach; ?>
</ul>
<?php $this->show('sidebar'); ?>
</aside>
</div>
<?php $this->end(); ?>
// _views/blog/_viewstart.php
<?php
$layout = 'blog/_layout.php';
// Load categories for sidebar (available in all blog templates)
$categories = db()->query('SELECT * FROM categories ORDER BY name')->fetchAll();
Page Template (Blog Post)
// _views/blog/post.php
<?php $this->extend(); ?>
<?php $this->block('title', $h($post->title) . ' - Blog'); ?>
<?php $this->block('head'); ?>
<meta property="og:title" content="<?= $h($post->title) ?>">
<meta property="og:description" content="<?= $h($post->excerpt) ?>">
<?php $this->end(); ?>
<?php $this->block('blog-content'); ?>
<article class="post">
<header>
<h1><?= $h($post->title) ?></h1>
<p class="meta">
By <?= render('parts/user-badge.php', ['user' => $post->author]) ?>
on <?= $fmt->date($post->published_at) ?>
</p>
</header>
<div class="content">
<?= $post->content ?>
</div>
<footer>
<p>Tags:
<?php foreach ($post->tags as $tag): ?>
<a href="/blog?tag=<?= $tag->id ?>"><?= $h($tag->name) ?></a>
<?php endforeach; ?>
</p>
</footer>
</article>
<?php $this->end(); ?>
<?php $this->block('sidebar'); ?>
<h3>Related Posts</h3>
<ul>
<?php foreach ($relatedPosts as $related): ?>
<li><a href="/blog/<?= $related->slug ?>"><?= $h($related->title) ?></a></li>
<?php endforeach; ?>
</ul>
<?php $this->end(); ?>
Admin Layout
// _views/admin/_layout.php
<?php $this->extend('_layout.php'); ?>
<?php $this->block('body-class', 'admin-section'); ?>
<?php $this->block('content'); ?>
<div class="admin-container">
<nav class="admin-sidebar">
<ul>
<li><a href="/admin">Dashboard</a></li>
<li><a href="/admin/users">Users</a></li>
<li><a href="/admin/posts">Posts</a></li>
<li><a href="/admin/settings">Settings</a></li>
</ul>
</nav>
<div class="admin-content">
<h1><?php $this->show('page-title', 'Admin'); ?></h1>
<?php $this->show('admin-content'); ?>
</div>
</div>
<?php $this->end(); ?>
Reusable Template Parts
For repeated UI components, create small templates in parts/ and include them with render().
User Badge
// _views/parts/user-badge.php
<span class="user-badge">
<img src="<?= $h($user->avatar_url ?? '/img/default-avatar.png') ?>"
alt="<?= $h($user->name) ?>"
class="avatar">
<a href="/users/<?= $user->id ?>"><?= $h($user->name) ?></a>
</span>
Usage:
<?= render('parts/user-badge.php', ['user' => $post->author]) ?>
Flash Messages
// _views/parts/flash-messages.php
<?php $messages = $_SESSION['flash'] ?? []; unset($_SESSION['flash']); ?>
<?php if ($messages): ?>
<div class="flash-messages">
<?php foreach ($messages as $type => $text): ?>
<div class="flash flash-<?= $type ?>"><?= $h($text) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
Pagination
// _views/parts/pagination.php
<?php
// Expects: $query (PartialQuery), $baseUrl (string)
$page = (int)($_GET['page'] ?? 1);
$perPage = 20;
$total = $query->count();
$totalPages = (int)ceil($total / $perPage);
if ($totalPages <= 1) return;
?>
<nav class="pagination">
<?php if ($page > 1): ?>
<a href="<?= $baseUrl ?>?page=<?= $page - 1 ?>">« Prev</a>
<?php endif; ?>
<?php for ($i = max(1, $page - 2); $i <= min($totalPages, $page + 2); $i++): ?>
<?php if ($i === $page): ?>
<span class="current"><?= $i ?></span>
<?php else: ?>
<a href="<?= $baseUrl ?>?page=<?= $i ?>"><?= $i ?></a>
<?php endif; ?>
<?php endfor; ?>
<?php if ($page < $totalPages): ?>
<a href="<?= $baseUrl ?>?page=<?= $page + 1 ?>">Next »</a>
<?php endif; ?>
</nav>
Passing Queries to Templates
Keep controllers simple by passing query objects rather than paginated results. Templates handle pagination, sorting, and display logic.
Controller
// _routes/admin/users.php
$users = db()->from('users')
->where('deleted_at IS NULL')
->orderBy('created_at DESC');
// Optionally apply filters from request
if ($search = $_GET['search'] ?? null) {
$users = $users->where('name LIKE ? OR email LIKE ?', ["%$search%", "%$search%"]);
}
if ($role = $_GET['role'] ?? null) {
$users = $users->where('role = ?', [$role]);
}
echo render('admin/users/list.php', [
'users' => $users, // Pass the query, not results
'search' => $search,
'role' => $role,
]);
Template with Pagination
// _views/admin/users/list.php
<?php $this->extend(); ?>
<?php $this->block('title', 'Manage Users'); ?>
<?php $this->block('page-title', 'Users'); ?>
<?php $this->block('admin-content'); ?>
<?php
// Template handles pagination
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 25;
$total = $users->count();
$totalPages = (int)ceil($total / $perPage);
$pagedUsers = $users
->limit($perPage)
->offset(($page - 1) * $perPage)
->fetchAll();
?>
<div class="toolbar">
<form method="get" class="search-form">
<input type="text" name="search" value="<?= $h($search ?? '') ?>" placeholder="Search...">
<select name="role">
<option value="">All Roles</option>
<option value="admin" <?= ($role ?? '') === 'admin' ? 'selected' : '' ?>>Admin</option>
<option value="user" <?= ($role ?? '') === 'user' ? 'selected' : '' ?>>User</option>
</select>
<button type="submit">Filter</button>
</form>
<a href="/admin/users/new" class="btn btn-primary">Add User</a>
</div>
<p>Showing <?= count($pagedUsers) ?> of <?= $total ?> users</p>
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($pagedUsers as $user): ?>
<tr>
<td><?= render('parts/user-badge.php', ['user' => $user]) ?></td>
<td><?= $h($user->email) ?></td>
<td><span class="badge badge-<?= $user->role ?>"><?= $user->role ?></span></td>
<td><?= $fmt->dateShort($user->created_at) ?></td>
<td>
<a href="/admin/users/<?= $user->id ?>/edit">Edit</a>
<a href="/admin/users/<?= $user->id ?>/delete"
onclick="return confirm('Delete this user?')">Delete</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?= render('parts/pagination.php', ['query' => $users, 'baseUrl' => '/admin/users']) ?>
<?php $this->end(); ?>
Why Pass Queries?
- Separation of concerns - Controllers filter and authorize; templates present
- Flexibility - Template can paginate, sort, or display differently without controller changes
- Lazy evaluation - Count and fetch happen only when needed
- Testability - Mock the query object for template testing
Guidelines: Templates may perform read-only query operations (pagination, counting, fetching). Avoid joins, conditional mutations, or business logic branching in templates—keep that in controllers.
Escaping Output
Always escape user-provided data. Use the $h() and $a() helpers set up in _viewstart.php:
// Text content - use $h()
<h1><?= $h($user->name) ?></h1>
// Attribute values - use $a() (semantically clearer, same function)
<input type="text" value="<?= $a($user->name) ?>" placeholder="<?= $a($placeholder) ?>">
// UNSAFE - never do this with user data
<h1><?= $user->name ?></h1>
Escaping contexts: $h() and $a() escape for HTML text and attribute contexts only. For other contexts:
// URL query parameters - use urlencode()
<a href="/search?q=<?= urlencode($query) ?>">Search</a>
// JavaScript - use json_encode()
<script>const user = <?= json_encode($user->name) ?>;</script>
When NOT to escape:
- Content already sanitized (e.g., HTML from a trusted WYSIWYG editor stored safely)
- URLs you construct yourself (but escape query parameters)
// Pre-sanitized content (trust the storage, not the display)
<div class="content"><?= $post->html_content ?></div>
Template Organization Tips
Naming Conventions
- Layouts:
_layout.php(underscore prefix) - ViewStart:
_viewstart.php - Parts:
parts/component-name.php(kebab-case) - Pages:
page-name.phporsection/page-name.php
Keep Templates Focused
Templates should primarily contain HTML with minimal logic. Move complex logic to:
- Controllers (data fetching, authorization)
- ViewStart files (common setup)
- Helper functions (formatting, calculations)
Too much logic:
<?php
// Bad - this belongs in a controller or service
$users = db()->query('SELECT * FROM users WHERE ...');
$stats = array_reduce($users, function($acc, $user) { ... });
?>
Just right:
<?php
// Good - template receives prepared data
foreach ($users as $user):
?>
<li><?= $h($user->name) ?></li>
<?php endforeach; ?>
Conditional Blocks
Check if optional blocks were defined:
// _views/_layout.php
<?php if (isset($this->blocks['sidebar'])): ?>
<aside class="sidebar">
<?php $this->show('sidebar'); ?>
</aside>
<?php endif; ?>
Complete Example: Blog Application
Directory Structure
_views/
├── _viewstart.php
├── _layout.php
├── home.php
├── blog/
│ ├── _viewstart.php
│ ├── _layout.php
│ ├── index.php
│ └── post.php
└── parts/
├── post-card.php
├── pagination.php
└── user-badge.php
Root ViewStart
// _views/_viewstart.php
<?php
$layout = '_layout.php';
$h = fn($s) => htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
$a = fn($s) => htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
$fmt = mini\fmt();
$currentUser = mini\auth()->user();
Blog ViewStart
// _views/blog/_viewstart.php
<?php
$layout = 'blog/_layout.php';
$categories = db()->query('SELECT * FROM categories ORDER BY name')->fetchAll();
$popularTags = db()->query('SELECT * FROM tags ORDER BY post_count DESC LIMIT 10')->fetchAll();
Blog Index (with query passed from controller)
// _views/blog/index.php
<?php $this->extend(); ?>
<?php $this->block('title', 'Blog'); ?>
<?php $this->block('blog-content'); ?>
<?php
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 10;
$pagedPosts = $posts->limit($perPage)->offset(($page - 1) * $perPage)->fetchAll();
?>
<h1>Latest Posts</h1>
<div class="post-grid">
<?php foreach ($pagedPosts as $post): ?>
<?= render('parts/post-card.php', ['post' => $post]) ?>
<?php endforeach; ?>
</div>
<?= render('parts/pagination.php', ['query' => $posts, 'baseUrl' => '/blog']) ?>
<?php $this->end(); ?>
Post Card Part
// _views/parts/post-card.php
<article class="post-card">
<?php if ($post->featured_image): ?>
<img src="<?= $h($post->featured_image) ?>" alt="">
<?php endif; ?>
<div class="post-card-body">
<h2><a href="/blog/<?= $h($post->slug) ?>"><?= $h($post->title) ?></a></h2>
<p class="meta">
<?= render('parts/user-badge.php', ['user' => $post->author]) ?>
· <?= $fmt->dateShort($post->published_at) ?>
</p>
<p><?= $h($post->excerpt) ?></p>
</div>
</article>
This structure scales well: add new sections by creating a subdirectory with its own _viewstart.php and _layout.php, inheriting from the base while customizing as needed.