Compose includes a template renderer with helpers and layouts and supports a “pages” convention that maps request paths to templates with optional code-behind scripts. A Plates bridge is available if you prefer Plates APIs.
The renderer is exposed via Compose\Template\RendererInterface
and backed by Compose\Template\TemplateRenderer
. The default factory registers:
templates/
(set via template.dir
).template.folders
and accessible with the alias::template
syntax.template.layout
.template.helpers
.Render a template directly from a handler:
use Compose\Template\RendererInterface;
use Laminas\Diactoros\Response\HtmlResponse;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class HomeAction implements RequestHandlerInterface
{
public function __construct(private RendererInterface $views) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$html = $this->views->render('home', [
'title' => 'Compose MVC',
'message' => 'Rendered from a request handler.',
], $request);
return new HtmlResponse($html);
}
}
Layouts are standard templates (compatible with the built-in engine and the Plates bridge):
<!-- templates/layouts/app.phtml -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= $this->e($title ?? 'Compose App') ?></title>
</head>
<body>
<?= $this->section('content') ?>
</body>
</html>
Enable a default layout by setting template.layout
(layouts::app
in this example). Override per render call via the fourth argument to render()
.
Helpers are registered through the helper registry. Helpers can be:
HelperRegistry::extend()
.'template' => [
'helpers' => [
App\View\HelperProvider::class, // extends registry
'asset' => static fn (string $path) => '/assets/' . ltrim($path, '/'),
],
],
Within templates, call helpers through $this->asset('css/app.css')
.
The Pages middleware is the primary feature of Compose. It automatically maps URL paths to templates in your filesystem, eliminating the need to define routes for every page. This “convention over configuration” approach makes it fast to scaffold new pages and intuitive to navigate your codebase.
The Pages middleware maps request paths to template files based on a simple convention:
/
→ pages/index.phtml
(the default page)/about
→ pages/about.phtml
/blog
→ pages/blog/index.phtml
or pages/blog.phtml
/blog/post
→ pages/blog/post.phtml
/docs/getting-started
→ pages/docs/getting-started.phtml
The middleware automatically handles:
/blog
will first check for pages/blog.phtml
, then fall back to pages/blog/index.phtml
./products/category/item
map to pages/products/category/item.phtml
..phtml
, but you can customize this via template.extension
.The Pages middleware uses a sophisticated matching algorithm to resolve URLs to templates:
Parse the URL path into segments (e.g., /blog/post/123
→ ['blog', 'post', '123']
)
blog/post/123
blog/post/123/index
blog/post
(and 123
becomes a URL parameter)blog/post/index
Check each candidate in the template resolution order (see below)
Example: For URL /products/electronics/laptop-15
:
products/electronics/laptop-15.phtml
products/electronics/laptop-15/index.phtml
products/electronics.phtml
(and laptop-15
becomes a parameter)products/electronics/index.phtml
products.phtml
(and electronics
, laptop-15
become parameters)products/index.phtml
index.phtml
(and products
, electronics
, laptop-15
become parameters)When processing a request, the Pages middleware resolves templates by checking in this order:
template.maps
) – Override specific pathspages.folders
) – Check mounted directories with namespace prefixespages.dir
) – The default location for pagesThis resolution order allows you to organize content flexibly while maintaining predictable behavior.
Configure the Pages middleware in your config/app.php
:
'pages' => [
'dir' => __DIR__ . '/../pages', // Base directory for pages
'namespace' => 'pages', // Optional: namespace for the base directory
'folders' => [ // Additional mounted directories
'admin' => __DIR__ . '/../pages-admin',
],
],
Configuration Keys:
dir
(string, required): The base directory where page templates and code-behind files are stored. This is the primary location the middleware searches for pages.
namespace
(string, optional, default: 'pages'
): The namespace prefix for the base directory. This is used internally for template resolution. In most cases, the default is sufficient.
folders
(array, optional): Named directories that can be mounted at URL prefixes. Each key is the URL segment prefix, and the value is the absolute path to the directory. This enables modular organization of pages (e.g., separating admin pages, API endpoints, or documentation).
Note: The default page name (index
) is currently hardcoded in the middleware and cannot be changed via configuration.
Related Template Configuration:
The Pages middleware also uses settings from the template
configuration:
'template' => [
'dir' => __DIR__ . '/../templates', // Base template directory
'extension' => 'phtml', // File extension for templates
'layout' => 'layouts::app', // Default layout template
'folders' => [ // Named template directories
'layouts' => __DIR__ . '/../templates/layouts',
],
'maps' => [ // Template path overrides
'error' => __DIR__ . '/../templates/error',
],
],
The template.extension
setting determines what file extension the Pages middleware looks for (default .phtml
).
Code-behind scripts bring server-side logic to your pages without requiring separate controller classes. A code-behind file has the same name as your template with an additional .php
extension: filename.phtml.php
.
When the Pages middleware matches a template, it checks for a corresponding code-behind file. If found, the script is executed before rendering the template. The code-behind script can control what data is passed to the template or return a complete response.
The code-behind file must return
one of three valid types. If the return value is a callable, it will be invoked using the framework’s invocation system, which supports automatic parameter resolution.
Callable Signature:
function (ServerRequestInterface $request, ...$urlParams): array|ResponseInterface
The callable receives:
ServerRequestInterface $request
(required first parameter) - The PSR-7 HTTP requestA code-behind script can return three different types:
1. Array – Merged into the template data:
// pages/dashboard.phtml.php
<?php
return [
'title' => 'Dashboard',
'stats' => ['users' => 150, 'posts' => 892],
];
2. Callable – Invoked with the request and URL parameters:
// pages/profile.phtml.php
<?php
use Psr\Http\Message\ServerRequestInterface;
return static function (ServerRequestInterface $request, string $username): array {
// Access query parameters
$tab = $request->getQueryParams()['tab'] ?? 'overview';
// Fetch data based on URL parameter
$user = getUserByUsername($username);
return [
'title' => 'Profile: ' . $username,
'user' => $user,
'activeTab' => $tab,
];
};
The callable receives:
ServerRequestInterface $request
– The PSR-7 request object/profile/john
passes "john"
as the second parameter)3. ResponseInterface – Returned directly, bypassing template rendering:
// pages/api/status.phtml.php
<?php
use Psr\Http\Message\ServerRequestInterface;
use Laminas\Diactoros\Response\JsonResponse;
return static function (ServerRequestInterface $request) {
return new JsonResponse([
'status' => 'ok',
'timestamp' => time(),
]);
};
This is useful for:
Here’s a full example showing a blog post page with code-behind:
// pages/blog/post.phtml.php
<?php
use Psr\Http\Message\ServerRequestInterface;
use Laminas\Diactoros\Response\RedirectResponse;
return static function (ServerRequestInterface $request, string $slug): mixed {
$post = findPostBySlug($slug);
// Return 404 redirect if post not found
if (!$post) {
return new RedirectResponse('/404');
}
return [
'title' => $post->title,
'post' => $post,
'relatedPosts' => findRelatedPosts($post->id, 3),
];
};
And the corresponding template:
<!-- pages/blog/post.phtml -->
<?php $this->layout('layouts::app', ['title' => $title]); ?>
<article>
<h1><?= $this->e($post->title) ?></h1>
<div class="content">
<?= $post->content ?>
</div>
</article>
<?php if (!empty($relatedPosts)): ?>
<aside>
<h2>Related Posts</h2>
<ul>
<?php foreach ($relatedPosts as $related): ?>
<li><a href="/blog/post/<?= $this->e($related->slug) ?>">
<?= $this->e($related->title) ?>
</a></li>
<?php endforeach; ?>
</ul>
</aside>
<?php endif; ?>
URL segments beyond the template path are passed as parameters to your code-behind callable. This enables clean, RESTful URLs:
// URL: /products/electronics/laptop-123
// File: pages/products.phtml.php
return static function (ServerRequestInterface $request, string $category, string $productId): array {
return [
'category' => $category, // "electronics"
'productId' => $productId, // "laptop-123"
];
};
How Parameters Work:
When a template matches with remaining URL segments, those segments are extracted and passed to your code-behind callable in order:
pages/products.phtml
/products/electronics/laptop-123
['electronics', 'laptop-123']
function(ServerRequestInterface $request, string $category, string $productId)
Type Hints and Validation:
You can use type hints to ensure parameters match your expectations. The framework’s invocation system will attempt to cast values appropriately:
return static function (ServerRequestInterface $request, int $userId): array {
// $userId will be cast to an integer
$user = findUserById($userId);
return ['user' => $user];
};
Variable Number of Parameters:
Use variadic parameters to accept any number of URL segments:
// URL: /docs/guide/getting-started/installation
// File: pages/docs.phtml.php
return static function (ServerRequestInterface $request, string ...$path): array {
// $path = ['guide', 'getting-started', 'installation']
$content = loadDocumentation(implode('/', $path));
return ['content' => $content];
};
Mount additional page directories using pages.folders
. This is useful for:
Configuration:
'pages' => [
'dir' => __DIR__ . '/../pages',
'folders' => [
'admin' => __DIR__ . '/../pages-admin',
'docs' => __DIR__ . '/../content/docs',
'api' => __DIR__ . '/../pages-api',
],
],
Requests will check these folders in order. For example, /admin/users
will look for:
pages-admin/users.phtml
(via the admin
folder mapping)pages/admin/users.phtml
(in the base pages
directory)Not every page needs code-behind logic. For simple content pages, create just the template:
<!-- pages/about.phtml -->
<?php $this->layout('layouts::app', ['title' => 'About Us']); ?>
<h1>About Our Company</h1>
<p>Founded in 2024, we build amazing applications with Compose.</p>
The page will render with an empty data context (beyond what the layout provides).
Handle form submissions in your code-behind:
// pages/contact.phtml.php
<?php
use Psr\Http\Message\ServerRequestInterface;
return static function (ServerRequestInterface $request): array {
$data = ['success' => false, 'errors' => []];
if ($request->getMethod() === 'POST') {
$params = $request->getParsedBody();
// Validate
if (empty($params['email'])) {
$data['errors']['email'] = 'Email is required';
}
// Process if valid
if (empty($data['errors'])) {
sendContactEmail($params);
$data['success'] = true;
}
}
return $data;
};
If a page template doesn’t exist, the Pages middleware passes control to the next middleware in the pipeline. Typically, this results in a 404 response from your error handling middleware.
You can customize 404 handling by:
pages/404.phtml
templateThe Pages middleware integrates seamlessly into the PSR-15 middleware pipeline. It’s typically positioned near the end of the pipeline, after authentication, session management, and other cross-cutting concerns:
'middleware' => [
10 => Compose\Http\OutputBufferMiddleware::class,
20 => Compose\Http\BodyParsingMiddleware::class,
30 => Compose\Http\SessionMiddleware::class,
40 => App\Middleware\AuthenticationMiddleware::class,
// ... other middleware
90 => Compose\Mvc\MvcMiddleware::class, // Contains routing and pages
],
Events: The Pages middleware dispatches a pages.match
event when a template is successfully matched. This allows you to hook into the page rendering lifecycle:
'subscribers' => [
App\Event\PageMatchListener::class,
],
Request Attributes: The middleware respects the standard PSR-7 request attributes. For example, the container can be accessed via $request->getAttribute('container')
.
Middleware Context: Since Pages is just middleware, you can wrap specific page routes with additional middleware by organizing them in separate folders and using nested middleware pipes in your configuration.
Keep code-behind focused: Code-behind scripts should handle page-specific logic and data fetching. Move complex business logic to service classes.
Use dependency injection: Access services through the container rather than using global state:
// pages/admin/dashboard.phtml.php
use Psr\Http\Message\ServerRequestInterface;
use Compose\Container\ContainerInterface;
return static function (ServerRequestInterface $request): array {
$container = $request->getAttribute('container');
$stats = $container->get(StatsService::class)->getDashboardStats();
return ['stats' => $stats];
};
Leverage layouts: Define common HTML structure in layouts to keep page templates focused on content.
Use URL parameters thoughtfully: Design clean, readable URLs that map naturally to your page structure.
Page not rendering:
pages.dir
is configured correctlyCode-behind not executing:
.phtml.php
extensionParameters not passed to callable:
ServerRequestInterface
Because the renderer only relies on Plates, you can reuse it in CLI commands, background jobs, or any other context by retrieving RendererInterface
from the container and calling render()
directly. Pass null
as the request when rendering outside HTTP.