Templating, Routing and Middleware by Hand

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5 (8.5.11), checked on 2026-10-05, with nyholm/psr7 1.8, nyholm/psr7-server and Slim 4.15; every output was captured by running the code under php -S and sending requests with curl. It builds, in a few hundred lines, the parts that every PHP framework provides: templates with layouts, a router, a container and a middleware pipeline. Seeing them small makes Laravel’s versions easier to understand: Blade, Routing, the service container and Middleware.

PHP as a template language

PHP started as a template language and still works as one. In templates, use the alternative syntax (if (…): … endif, foreach (…): … endforeach), which reads better between HTML tags than braces, and the short echo tag <?=, which is always available. Escape every value with one helper; the only unescaped output is HTML that the application rendered itself (here, the page inside the layout).

<?php /** templates/books.php */ /** @var list<array{id:int,title:string,year:int}> $books */ ?>
<h1>Books</h1>
<?php if ($books === []): ?>
  <p>No books yet.</p>
<?php else: ?>
  <ul>
  <?php foreach ($books as $book): ?>
    <li><a href="/books/<?= (int) $book['id'] ?>"><?= e($book['title']) ?></a> (<?= e($book['year']) ?>)</li>
  <?php endforeach ?>
  </ul>
<?php endif ?>
<?php /** templates/layout.php */ /** @var string $title */ /** @var string $content */ ?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title><?= e($title) ?> | Bookshelf Lite</title></head>
<body>
<nav><a href="/books">Books</a></nav>
<main><?= $content /* already-rendered HTML from the page template */ ?></main>
</body>
</html>

The view class renders a template into a string with output buffering, then renders the layout around it. Each template runs in a fresh static closure, so it sees only the variables it was given (no $this, no globals):

<?php
declare(strict_types=1);

function e(string|int|null $value): string
{
    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}

final class View
{
    public function __construct(private readonly string $directory) {}

    /** Render a page template inside the layout. */
    public function page(string $template, string $title, array $data = []): string
    {
        return $this->render('layout', ['title' => $title, 'content' => $this->render($template, $data)]);
    }

    private function render(string $template, array $data): string
    {
        $file = "{$this->directory}/$template.php";
        return (static function () use ($file, $data): string {   // static: no $this inside templates
            extract($data, EXTR_SKIP);
            ob_start();
            require $file;
            return ob_get_clean();
        })();
    }
}

The weakness of plain PHP templates is that escaping is opt-in: forgetting e() once is an XSS bug, and nothing warns about it. Template engines invert the default: Blade’s {{ $title }} and Twig’s {{ title }} escape automatically, and unescaped output needs an explicit {!! !!} or |raw, which stands out in review.

A front controller and a router

All requests reach public/index.php (through try_files in Nginx, or php -S localhost:8000 public/index.php). A router matches the method and path against patterns with parameters, and distinguishes 404 Not Found (no pattern matches) from 405 Method Not Allowed (the path exists, the method does not), which must list the allowed methods in an Allow header:

<?php
declare(strict_types=1);

final class Router
{
    /** @var array<string, array<string, callable>> pattern => method => handler */
    private array $routes = [];

    public function add(string $method, string $pattern, callable $handler): void
    {
        $this->routes[$pattern][$method] = $handler;
    }

    /** @return array{0:int, 1:?callable, 2:array<string,string>, 3:list<string>} status, handler, parameters, allowed methods */
    public function match(string $method, string $path): array
    {
        foreach ($this->routes as $pattern => $handlers) {
            // "/books/{id}" -> "#^/books/(?<id>[^/]+)$#"
            $regex = '#^' . preg_replace('#\{(\w+)\}#', '(?<$1>[^/]+)', $pattern) . '$#';
            if (preg_match($regex, $path, $m)) {
                $params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
                return isset($handlers[$method])
                    ? [200, $handlers[$method], $params, []]
                    : [405, null, [], array_keys($handlers)];
            }
        }
        return [404, null, [], []];
    }
}

Real routers (FastRoute, used by Slim; Symfony Routing, used by Laravel) compile all patterns into one regular expression or a lookup tree instead of trying them one by one, support constraints ({id:\d+}), optional segments and named routes for generating URLs, and cache the compiled result.

PSR-7 requests, PSR-17 factories and a PSR-11 container

The handlers above receive a PSR-7 ServerRequestInterface instead of reading superglobals, and return a PSR-7 ResponseInterface instead of calling header() and echo. That makes them testable (build a request object, call the handler, inspect the response) and composable. A PSR-17 factory creates the objects (here Nyholm\Psr7), and nyholm/psr7-server builds the request from the superglobals. Services come from a PSR-11 container, which creates each one on first use:

<?php
declare(strict_types=1);

use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;

final class NotFound extends RuntimeException implements NotFoundExceptionInterface {}

/** A minimal PSR-11 container: factories, each service created once. */
final class Container implements ContainerInterface
{
    private array $instances = [];

    /** @param array<string, callable(self): object> $factories */
    public function __construct(private readonly array $factories) {}

    public function get(string $id): mixed
    {
        return $this->instances[$id] ??= ($this->factories[$id] ?? throw new NotFound("No service $id"))($this);
    }

    public function has(string $id): bool
    {
        return isset($this->factories[$id]);
    }
}

Laravel’s container goes much further: it autowires constructor parameters by reading their types with Reflection, so most classes need no factory at all, and it supports interfaces bound to implementations, scoped and singleton lifetimes, and contextual binding.

PSR-15 middleware

Middleware is code that wraps the application: each piece receives the request and a handler for "everything after me", and may change the request, return early (authentication, rate limiting), or change the response on the way back (headers, timing, compression). PSR-15 defines the two interfaces: MiddlewareInterface::process($request, $handler) and RequestHandlerInterface::handle($request). A pipeline is a handler that calls the next middleware, and finally the application:

<?php
declare(strict_types=1);

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/** Runs PSR-15 middleware in order, then the final handler. */
final class Pipeline implements RequestHandlerInterface
{
    /** @param list<MiddlewareInterface> $middleware */
    public function __construct(private array $middleware, private readonly RequestHandlerInterface $final) {}

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $next = array_shift($this->middleware);
        return $next === null
            ? $this->final->handle($request)
            : $next->process($request, $this);         // each middleware calls $handler->handle() to continue
    }
}

final class TimingMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $start = hrtime(true);
        $response = $handler->handle($request);       // everything after this line runs on the way out
        return $response->withHeader('Server-Timing', sprintf('app;dur=%.1f', (hrtime(true) - $start) / 1e6));
    }
}

final class AdminOnlyMiddleware implements MiddlewareInterface
{
    public function __construct(private readonly Psr\Http\Message\ResponseFactoryInterface $responses) {}

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if (str_starts_with($request->getUri()->getPath(), '/admin') && $request->getHeaderLine('X-Role') !== 'admin') {
            return $this->responses->createResponse(403);   // short-circuit: the handler never runs
        }
        return $handler->handle($request->withAttribute('checked_at', time()));
    }
}

The front controller wires everything together: the container, the routes, a final handler that turns a route’s result into a response, the pipeline, and the code that emits the PSR-7 response with http_response_code(), header() and echo:

<?php
// public/index.php
declare(strict_types=1);

use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

require __DIR__ . '/../vendor/autoload.php';
// ... require View, Router, Container and Pipeline (or autoload them with PSR-4) ...

$container = new Container([
    Psr17Factory::class => fn() => new Psr17Factory(),
    View::class => fn() => new View(__DIR__ . '/../templates'),
    'books' => fn() => [1 => ['id' => 1, 'title' => 'Kindred', 'year' => 1979], 2 => ['id' => 2, 'title' => 'Dawn <3', 'year' => 1987]],
]);
$factory = $container->get(Psr17Factory::class);

$router = new Router();
$router->add('GET', '/books', fn(ServerRequestInterface $r) => $container->get(View::class)
    ->page('books', 'Books', ['books' => array_values($container->get('books'))]));
$router->add('GET', '/books/{id}', function (ServerRequestInterface $r, array $params) use ($container, $factory) {
    $book = $container->get('books')[(int) $params['id']] ?? null;
    return $book === null
        ? $factory->createResponse(404)
        : $factory->createResponse(200)->withHeader('Content-Type', 'application/json')
            ->withBody($factory->createStream(json_encode($book, JSON_THROW_ON_ERROR)));
});
$router->add('GET', '/admin', fn() => 'admin area');

// The final handler: route, call the controller, turn its result into a response
$app = new class ($router, $factory) implements RequestHandlerInterface {
    public function __construct(private Router $router, private Psr17Factory $factory) {}

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        [$status, $handler, $params, $allowed] = $this->router->match($request->getMethod(), $request->getUri()->getPath());
        if ($handler === null) {
            $response = $this->factory->createResponse($status);
            return $allowed === [] ? $response : $response->withHeader('Allow', implode(', ', $allowed));
        }
        $result = $handler($request, $params);
        return $result instanceof ResponseInterface ? $result : $this->factory->createResponse(200)
            ->withHeader('Content-Type', 'text/html; charset=utf-8')->withBody($this->factory->createStream($result));
    }
};

$request = (new ServerRequestCreator($factory, $factory, $factory, $factory))->fromGlobals();
$response = (new Pipeline([new TimingMiddleware(), new AdminOnlyMiddleware($factory)], $app))->handle($request);

// Emit the PSR-7 response
http_response_code($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
    foreach ($values as $value) {
        header("$name: $value", false);
    }
}
echo $response->getBody();
$ curl -i localhost:8000/books
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Server-Timing: app;dur=0.8

<!doctype html>
...
<main><h1>Books</h1>
  <ul>
      <li><a href="/books/1">Kindred</a> (1979)</li>
      <li><a href="/books/2">Dawn &lt;3</a> (1987)</li>
    </ul>
</main>
...
$ curl localhost:8000/books/2
{"id":2,"title":"Dawn <3","year":1987}
$ curl -i -X POST localhost:8000/books
HTTP/1.1 405 Method Not Allowed
Allow: GET
$ curl -o /dev/null -w '%{http_code}\n' localhost:8000/admin
403
$ curl -H 'X-Role: admin' localhost:8000/admin
admin area

The X-Role header stands in for a real authentication check (the session from Authentication Without a Framework); never trust a header like it in production.

The same application in Slim 4

A micro-framework provides exactly these pieces, tested and complete: Slim 4 routes with FastRoute, uses any PSR-7 implementation, runs PSR-15 middleware and emits the response. The same JSON route, with timing middleware and error handling:

<?php
declare(strict_types=1);

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Slim\Factory\AppFactory;

require __DIR__ . '/../vendor/autoload.php';

$books = [1 => ['id' => 1, 'title' => 'Kindred', 'year' => 1979]];

$app = AppFactory::create();                       // finds the installed PSR-17 implementation (Nyholm here)
$app->addRoutingMiddleware();
$app->addErrorMiddleware(displayErrorDetails: false, logErrors: true, logErrorDetails: true);

$app->add(function (Request $request, Handler $handler): Response {   // PSR-15 middleware as a closure
    $start = hrtime(true);
    return $handler->handle($request)->withHeader('Server-Timing', sprintf('app;dur=%.1f', (hrtime(true) - $start) / 1e6));
});

$app->get('/books/{id:\d+}', function (Request $request, Response $response, array $args) use ($books): Response {
    $book = $books[(int) $args['id']] ?? null;
    if ($book === null) {
        return $response->withStatus(404);
    }
    $response->getBody()->write(json_encode($book, JSON_THROW_ON_ERROR));
    return $response->withHeader('Content-Type', 'application/json');
});

$app->run();
$ composer require slim/slim:^4 nyholm/psr7 nyholm/psr7-server
$ curl -i localhost:8000/books/1
HTTP/1.1 200 OK
Content-Type: application/json
Server-Timing: app;dur=2.2

{"id":1,"title":"Kindred","year":1979}
$ curl -i localhost:8000/books/abc          # does not match {id:\d+}
HTTP/1.1 404 Not Found
$ curl -i -X POST localhost:8000/books/1
HTTP/1.1 405 Method Not Allowed
Allow: GET

Mezzio (the Laminas project) is the same idea with a stronger emphasis on PSR-15 pipelines and a choice of router, container and template engine; it is a common path for applications that started on Zend Framework.

Why a full framework pays off

The code on this page handles routing, templates, a container and middleware. A real application also needs validation with error messages, CSRF protection, sessions and authentication, database access with migrations, mail, queues, caching, logging, configuration per environment, error pages, testing helpers and a command line. A micro-framework leaves each of those to you; a full framework such as Laravel or Symfony provides them, integrated and documented, with conventions that let a new team member find the code. That is the trade-off behind the rest of this section, which starts at Laravel Getting Started.

Where older books and tutorials are outdated

  • One PHP file per URL with included header and footer files (Powers ch. 5, Nixon) works for small sites; a front controller with a router centralises routing, escaping and access control.

  • Printing with echo and header() from deep inside the code makes responses impossible to test or change afterwards; PSR-7 response objects (or a framework’s response classes) return them instead.

  • Hand-written MVC frameworks of the PSR-0 era (Kromann ch. 21, Wiley) are replaced by Composer packages that implement PSR-7, PSR-11 and PSR-15, and by micro-frameworks built on them.