> For the complete documentation index, see [llms.txt](https://docs.pressgang.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.pressgang.dev/template-routing.md).

# Template Routing

## Overview

Template routing lets requests find their controllers **by convention** — most themes need no template PHP files at all. No more three-line stub files whose only job is naming a controller and a Twig template: the filename already told us everything.

Think of it as the ship's watch rota: every request already knows its station.

Template routing is **opt-in**. Enable it by adding the service provider to your child theme's `config/service-providers.php`:

{% code title="config/service-providers.php" lineNumbers="true" %}

```php
return [
    // PressGang defaults — always keep these
    \PressGang\ServiceProviders\TimberServiceProvider::class,
    \PressGang\ServiceProviders\SeoServiceProvider::class,

    // Opt-in: convention-based template routing
    \PressGang\ServiceProviders\TemplateRoutingServiceProvider::class,
];
```

{% endcode %}

{% hint style="info" %}
Themes built on explicit template stubs are completely untouched by framework upgrades — nothing changes until you list the provider. And even once enabled, **a physical template file in your child theme always wins**, so you can adopt convention routing incrementally: delete stubs one at a time.
{% endhint %}

## How It Works

When WordPress resolves a request, PressGang records the template hierarchy candidates (most specific first). If the request falls through to a **parent-theme** template — meaning your child theme had no stub for it — the dispatcher resolves a controller from those candidates and renders it.

Mechanically: `template_include` must return a PHP file for WordPress to load, so the dispatcher hands it the parent theme's `dispatch.php` — a one-line landing file that calls `ControllerFactory::dispatch()`. The routing *decision* happens in the filter; `dispatch.php` is just where the request touches down. (Custom route handlers reuse the same landing file — see below.)

For each candidate, resolution tries:

1. **Your config map** — an explicit entry in `config/controllers.php`
2. **Naming convention** — a matching controller in your child theme's `Controllers` namespace

The matching `{candidate}.twig` in your `views/` directory renders automatically; if it doesn't exist, the controller's own template inference applies.

## The Naming Conventions

Beyond the literal StudlyCase name, the dispatcher understands WordPress template hierarchy semantics — matching the PressGang convention of **plural controllers for archives, singular for single views**:

| Candidate             | Resolves to           | Rule                               |
| --------------------- | --------------------- | ---------------------------------- |
| `search`              | `SearchController`    | StudlyCase                         |
| `front-page`          | `FrontPageController` | StudlyCase                         |
| `archive-event`       | `EventsController`    | `archive-{type}` → pluralised type |
| `single-event`        | `EventController`     | `single-{type}` → the subject      |
| `taxonomy-event-type` | `EventTypeController` | `taxonomy-{tax}` → the subject     |

Parent framework controllers are never matched by convention — the parent theme's own templates already route to them — so dispatch only activates for controllers *you* define.

## Hyphenated Template Names

WordPress builds hierarchy candidates from your registered keys, so a taxonomy named `event_type` traditionally demands `taxonomy-event_type.php` — an underscore in a world of kebab-case filenames. With routing enabled, every underscored candidate gets a hyphenated twin, so `taxonomy-event-type.php` (and `taxonomy-event-type.twig`) work too. Underscored names keep working.

## The Config Map

Most themes need **no entries at all** — conventions cover the common cases. Use `config/controllers.php` only when a controller's name defies convention:

{% code title="config/controllers.php" lineNumbers="true" %}

```php
return [
    'archive-event' => \MyTheme\Controllers\WhatsOnController::class,
];
```

{% endcode %}

An explicit map entry always beats convention for its candidate.

## File-less Page Templates

Page templates traditionally require physical files for WordPress to discover their `Template Name:` headers. With routing enabled, register them declaratively instead — no `page-templates/` directory:

{% code title="config/page-templates.php" lineNumbers="true" %}

```php
return [
    'page-templates/contact-page.php' => 'Contact Page',
    'page-templates/grid-page.php'    => 'Grid Page',
];
```

{% endcode %}

Each registered template resolves to its `{Slug}Controller` by convention (`sidebar-page` → `SidebarPageController`), falling back to the framework `PageController`, and renders `{slug}.twig`.

{% hint style="info" %}
**Migrating an existing theme?** Use the legacy file-shaped ids shown above — they match the `_wp_page_template` values already stored on your pages, so assignments and the admin dropdown carry over with **no data migration**. New themes can use bare slugs like `'contact-page'`.
{% endhint %}

## When to Keep a Template File

A stub is still the right tool when there's genuine logic in template selection — the file always wins over dispatch, so nothing fights you:

{% code title="single-hit.php" lineNumbers="true" %}

```php
use MyTheme\Controllers\HitController;
use MyTheme\Controllers\SidebarPageController;

global $post;

if ( $post->post_parent === 0 ) {
    PressGang\PressGang::render( controller: HitController::class, twig: 'single-hit.twig' );
} else {
    PressGang\PressGang::render( controller: SidebarPageController::class, twig: 'sidebar-page.twig' );
}
```

{% endcode %}

## Custom Route Handlers

`config/routes.php` maps custom URL patterns (via the Upstatement Routes library) to a template filename — or, for routes that need logic before rendering, to a class implementing `RouteHandlerInterface`:

{% code title="config/routes.php" lineNumbers="true" %}

```php
return [
    'archive/:year'     => 'archive-year.php',
    'route/:slug/news/' => \MyTheme\Routes\NewsRoute::class,
];
```

{% endcode %}

A handler receives the matched route parameters and is responsible for loading a response — typically by building query args (Quartermaster's `toArgs()` is made for this) and handing off to the dispatcher:

{% code title="src/Routes/NewsRoute.php" lineNumbers="true" %}

```php
namespace MyTheme\Routes;

use PressGang\Quartermaster\Quartermaster;
use PressGang\Routes\RouteHandlerInterface;
use PressGang\Templates\TemplateHierarchy;

class NewsRoute implements RouteHandlerInterface {

    public function handle( array $params ): void {

        // This route renders the news listing regardless of what WP's
        // conditionals make of the query (empty paged listings flag 404).
        TemplateHierarchy::prepend( 'taxonomy-event-type' );

        \Routes::load(
            'dispatch.php',
            [ 'slug' => $params['slug'] ?? null ],
            Quartermaster::posts( 'post' )
                ->paged( (int) get_option( 'posts_per_page' ), (int) ( $params['paged'] ?? 1 ) )
                ->toArgs()
        );
    }
}
```

{% endcode %}

`TemplateHierarchy::prepend()` seeds the candidate your route *means*, so controller resolution stays deterministic even when WordPress's conditionals disagree (an empty page 2, for example).

## Precedence, In One Breath

**Template file → config map → naming convention.** Explicit always beats implicit, and your child theme always beats the framework.

### Controller template mappings

Use a parent controller when a view needs no additional context. In `config/controllers.php`, map a hierarchy candidate to a class string or `[ 'controller' => PageController::class, 'template' => 'page/research-subpage.twig' ]` (with the appropriate imported or fully qualified controller class). The explicit Twig path takes precedence over candidate discovery. Omitting `template`, or setting it to null, retains candidate discovery and constructor defaults. Candidate specificity and physical child PHP template precedence are unchanged. Keep custom controllers for additional getters, traits or behaviour. Before removing a class, check its class-derived rendering filters and action consumers; reusing the parent changes those hook names. This does not accept arbitrary constructor arguments.
