> 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/controllers.md).

# Controllers

## Overview

In PressGang, controllers manage the display logic for different types of pages and templates, often reflecting the WordPress template hierarchy, e.g., `PageController`. Conventionally, they use singular names (e.g., `PostController`) to represent single pages, and plural names (e.g., `PostsController`) to represent archive pages. Essentially, these classes build the Timber context that gets passed to the views (Twig templates).

Controllers are your first mates — they prepare everything the view needs, then hand it off cleanly.

### Naming child controllers

Use a singular name for a single post or page and a plural name for a collection or archive: `ConferenceController` and `ConferencesController`. The singular name already identifies a single view; do not add `Single` as a prefix or suffix. A listing backed by a WordPress page still uses the collection name, even when its controller extends `PageController`.

Name taxonomy controllers after their subject (`EventTypeController`). Keep purpose-based names for special views, such as `SearchController` and `NotFoundController`.

Controller names need not copy template filenames. A `conference-single.php` stub can render `ConferenceController` explicitly while retaining its Twig path and stored page-template ID. When renaming a class, update imports, render calls, controller maps and consumers of class-derived hooks. Verify routing: the optional dispatcher may require an explicit map for a legacy template name.

### AbstractController Base Class

The `AbstractController` class provides common functionalities for all controllers, including context management and template rendering.

### Key Methods

* `__construct(string|null $template = null)`: Initializes the controller with the specified Twig template and attaches the base `Timber::context()`.
* `get_context()`: Builds and returns the context array for the template.
* `render()`: Renders the Twig template with the current context, applying PressGang filters and actions.

### Available Controllers

PressGang ships with controllers for all the common WordPress template types:

| Controller           | Template        | Purpose                                          |
| -------------------- | --------------- | ------------------------------------------------ |
| `PageController`     | `page.twig`     | Standard WordPress pages                         |
| `PostController`     | `single.twig`   | Single post views (auto-detects post type)       |
| `PostsController`    | `archive.twig`  | Archive listings, categories, search results     |
| `SearchController`   | `search.twig`   | Search results (extends PostsController)         |
| `AuthorController`   | `author.twig`   | Author archive pages (extends PostsController)   |
| `TaxonomyController` | varies          | Taxonomy archive pages (extends PostsController) |
| `CommentsController` | `comments.twig` | Comments template                                |
| `NotFoundController` | `404.twig`      | 404 error page                                   |

WooCommerce controllers are also provided under `PressGang\Controllers\WooCommerce\`.

### WooCommerce Taxonomy Templates

WooCommerce hands every product archive to the theme's `woocommerce.php`, and `WC_Template_Loader` places that file **ahead of** `taxonomy-{taxonomy}.php` in its candidate list. Because PressGang ships a `woocommerce.php`, WordPress' per-taxonomy PHP template route is unavailable — a `taxonomy-product_brand.php` in your theme would be silently ignored.

`ProductsController` restores that route in the Twig layer. Product taxonomy archives resolve:

| Candidate                              | Used when                                |
| -------------------------------------- | ---------------------------------------- |
| `woocommerce/taxonomy-{taxonomy}.twig` | The theme provides one for that taxonomy |
| `woocommerce/archive-product.twig`     | Fallback for every product archive       |

So a promotions taxonomy registered as `product_promotion` picks up `views/woocommerce/taxonomy-product-promotion.twig` when present. Underscores become hyphens, matching `PostsController::infer_template()`. Themes without a taxonomy-specific template resolve exactly as before.

This applies to `product_cat` and `product_tag` as well as to any custom taxonomy registered against the `product` post type.

A product taxonomy can also take its own **controller**, resolved child-theme-first by the same `{Taxonomy}Controller` convention used elsewhere in PressGang:

| Taxonomy            | Controller                                            |
| ------------------- | ----------------------------------------------------- |
| `product_promotion` | `ProductPromotionController` or `PromotionController` |
| `product_brand`     | `ProductBrandController` or `BrandController`         |

The full taxonomy name is tried first, then the name with a leading `product_` stripped — that prefix is redundant inside the `Controllers\WooCommerce\` namespace.

`WooCommerceControllerResolver` tries that class before falling back to the shared `ProductsController`, so a theme can give one taxonomy bespoke context without overriding the controller that also serves the shop and every other product archive. Extend `ProductsController` to inherit the products query and shop sidebar.

## Example: `PageController`

{% code title="src/Controllers/PageController.php" lineNumbers="true" %}

```php
namespace PressGang\Controllers;

use Timber\Post;
use Timber\Timber;

class PageController extends AbstractController {
    protected Post $post;

    public function __construct(string|null $template = 'page.twig') {
        parent::__construct($template);
    }

    protected function get_post(): Post {
        if (empty($this->post)) {
            $this->post = Timber::get_post();
        }
        return $this->post;
    }

    protected function get_context(): array {
        $post = $this->get_post();
        $this->context['page'] = $post;
        $this->context['post'] = $post;
        return $this->context;
    }
}
```

{% endcode %}

## Usage in Templates

Controllers are utilized in standard WordPress template files. PressGang maintains the familiar WordPress template hierarchy — you still create `page.php`, `single.php`, `archive.php`, etc. — but instead of writing queries and HTML, you delegate to a controller.

{% tabs %}
{% tab title="PressGang::render() (Recommended)" %}
The static `render()` method resolves the controller and template for you:

{% code title="page.php" %}

```php
use PressGang\Controllers\PageController;

PressGang\PressGang::render(controller: PageController::class, twig: 'front-page.twig');
```

{% endcode %}

You can also let PressGang infer the controller automatically from the template filename:

{% code title="page.php" %}

```php
PressGang\PressGang::render(template: 'page.php');
```

{% endcode %}
{% endtab %}

{% tab title="Direct Instantiation" %}
For more control, instantiate the controller directly:

{% code title="front-page.php" %}

```php
use PressGang\Controllers\PageController;

(new PageController('front-page.twig'))->render();
```

{% endcode %}
{% endtab %}
{% endtabs %}

{% hint style="info" %}
Tired of writing stub files at all? With [Template Routing](/template-routing.md) enabled, requests resolve to controllers by naming convention — most themes need no template PHP files.
{% endhint %}

## Context Getters: Declare the Template Contract

Wiring context keys one line at a time gets old fast. Instead, declare the keys your template needs and let each one populate from its matching getter:

{% code title="src/Controllers/FrontPageController.php" lineNumbers="true" %}

```php
namespace MyTheme\Controllers;

use PressGang\Controllers\PageController;
use PressGang\Quartermaster\Quartermaster;

class FrontPageController extends PageController {

    /**
     * Template contract for front-page.twig: latest news and
     * events, each populated from its get_{key}() getter.
     *
     * @var array<int|string, string>
     */
    protected array $context_getters = [
        'news',
        'events',
    ];

    protected function get_news(): array {
        return Quartermaster::posts( 'post' )
            ->limit( 4 )
            ->toArray();
    }

    protected function get_events(): array {
        return Quartermaster::posts( 'event' )
            ->limit( 4 )
            ->toArray();
    }
}
```

{% endcode %}

Each plain entry calls `get_{key}()`; use `'key' => 'method'` to point a key at a differently-named getter. The manifest is applied after `get_context()` and before the `pressgang_{controller}_context` filter, so both extension points still work — and with a manifest, most controllers don't need a `get_context()` override at all.

{% hint style="info" %}
This is the controller counterpart to the `HandlesDynamicGetters` trait on models: getters own the *fetching*, the manifest declares which of them form the *template contract*. Keep it a declared list — the framework deliberately never auto-publishes getters, or your internal helpers would silently become template API.
{% endhint %}

Expose additional prepared data, not a duplicate of every model field. Read presentation-only metadata with `post.meta('intro_title')` or `term.meta()` in Twig, retaining the appropriate output escaping. Local Twig variables are useful for repeated fields. Keep query construction, relationship normalization and selection/enrichment rules in PHP.

A manifest invokes each entry once per application; it is not a general getter cache. Add a cache only when another getter or execution path needs the same result. Avoid one-use getter/resolver pairs. Before removing context keys, inspect inherited block bodies, includes, macro arguments, dynamic access and PHP hooks.

## Pagination belongs to the displayed collection

`PostsController` exposes `posts`, `pagination` and `page_title` for the current WordPress query — those three keys are the whole listing contract. Keep that inherited contract for ordinary archives. A custom PageController listing can instead pass pagination explicitly from its own collection:

```twig
{% include 'partials/modules/pagination.twig' with {
    pagination: news_items.pagination()
} %}
```

This removes a forwarding getter without changing the partial's input. `PostQuery::pagination()` already caches the pagination object. Keep the collection cache if PHP also uses it for a heading or enrichment; otherwise a single-use context getter need not cache its result. Do not build a second query for pagination, and do not add pagination to an unpaged listing.

## Working with ACF Values

ACF relationship and post-object fields return raw `WP_Post` objects or IDs — but Twig wants Timber posts. Convert with the same mapper the ACF options context manager uses:

{% code title="Controller" lineNumbers="true" %}

```php
use PressGang\ACF\TimberMapper;

protected function get_related(): array {
    return TimberMapper::to_timber_posts( $this->get_post()->meta( 'related_items' ) );
}
```

{% endcode %}

It accepts the raw field value directly (empty and `false` values are fine) and returns a clean array of `Timber\Post` objects. Prefer this explicit conversion over enabling Timber's global `timber/meta/transform_value` filter, which silently changes the return type of *every* `meta()` call — and doesn't reach values inside flexible-content or repeater sub-fields anyway.

## Filters and Actions

The `render()` method fires several hooks, giving you fine-grained control over any controller's output:

* **`pressgang_{controller}_template`** — filter the Twig template path before rendering.
* **`pressgang_{controller}_context`** — filter the context array before it reaches Twig.
* **`pressgang_render_{controller}`** — action fired just before `Timber::render()`.

The `{controller}` placeholder is the snake\_case version of the controller class name, e.g., `pressgang_page_controller_template`.

## Extending Controllers in Child Themes

To extend the functionality of a parent theme controller in a child theme, create a new controller class in the child theme that inherits from the parent controller.

{% code title="src/Controllers/ChildPageController.php" lineNumbers="true" %}

```php
namespace ChildTheme\Controllers;

use PressGang\Controllers\PageController;

class ChildPageController extends PageController {
    protected function get_context(): array {
        $context = parent::get_context();
        $context['custom_data'] = 'Additional data';
        return $context;
    }
}
```

{% endcode %}

Then use it in your child theme's template:

{% code title="child-theme/page.php" %}

```php
use ChildTheme\Controllers\ChildPageController;

PressGang\PressGang::render(controller: ChildPageController::class);
```

{% endcode %}

This setup allows the child theme to inherit and extend the logic defined in the parent theme controllers, promoting code reuse and maintainability.

### Note on MVC Abstraction

{% hint style="info" %}
While these controllers are named similarly to traditional MVC Controllers, they function more closely as **View Models**.
{% endhint %}

In classic MVC:

* **Model:** Handles data and business logic.
* **View:** Manages the display of information.
* **Controller:** Acts as an intermediary, handling user input, updating the Model, and refreshing the View.

In PressGang, the Controllers primarily prepare and manage context data for the View (Twig templates), aligning more with the View Model pattern. They focus on preparing data for the View without directly handling user input or business logic.

{% hint style="danger" %}
Controllers must **not** perform writes, remote requests, or access request globals like `$_GET` or `$_POST`.
{% endhint %}
