Skip to content

Template Controllers ​

Template controllers provide full control over template rendering. Use #[AsTemplateController] to handle specific WordPress templates.

Basic Template Controller ​

php
<?php
// app/Controllers/SingleController.php

namespace App\Controllers;

use Studiometa\Foehn\Attributes\AsTemplateController;
use Studiometa\Foehn\Contracts\TemplateControllerInterface;
use Studiometa\Foehn\Contracts\ViewEngineInterface;
use Studiometa\Foehn\Views\TemplateContext;

#[AsTemplateController('single')]
final class SingleController implements TemplateControllerInterface
{
    public function __construct(
        private readonly ViewEngineInterface $view,
    ) {}

    public function handle(TemplateContext $context): ?string
    {
        $post = $context->post;

        $context = $context->with('related', $this->getRelatedPosts($post));

        return $this->view->render('single', $context);
    }

    private function getRelatedPosts($post): array
    {
        return \Timber\Timber::get_posts([
            'post_type' => $post->post_type,
            'posts_per_page' => 3,
            'post__not_in' => [$post->ID],
        ]);
    }
}

TemplateContext ​

The handle() method receives a typed TemplateContext object that provides:

  • Typed properties for Timber globals (post, posts, site, user)
  • Safe casting for custom post types
  • Immutable updates via with() and merge()
  • DTO support for type-safe custom data

Typed Properties ​

php
public function handle(TemplateContext $context): ?string
{
    // Typed access to Timber globals
    $post = $context->post;     // ?Post
    $posts = $context->posts;   // ?PostCollectionInterface
    $site = $context->site;     // Site
    $user = $context->user;     // ?User

    return $this->view->render('single', $context);
}

Custom Post Type Casting ​

Use post() method to safely cast to your custom post type:

php
use App\Models\Product;

public function handle(TemplateContext $context): ?string
{
    // Returns ?Product with full IDE support
    $product = $context->post(Product::class);

    if ($product === null) {
        return null; // Let WordPress handle it
    }

    // Full autocomplete for Product methods
    $context = $context->with('price', $product->formattedPrice());

    return $this->view->render('single-product', $context);
}

Typed Posts Collection ​

Use posts() method to validate the collection contains your expected post type:

php
use App\Models\Product;

#[AsTemplateController('archive-product')]
final class ProductArchiveController implements TemplateControllerInterface
{
    public function handle(TemplateContext $context): ?string
    {
        // Returns ?PostCollectionInterface<Product>
        $products = $context->posts(Product::class);

        if ($products === null) {
            return null;
        }

        // All items in $products are Product instances
        foreach ($products as $product) {
            $product->formattedPrice(); // IDE support
        }

        return $this->view->render('archive-product', $context);
    }
}

Adding Data (Immutable) ​

The context is immutable. Use with() or merge() to add data:

php
public function handle(TemplateContext $context): ?string
{
    // Single key
    $context = $context->with('featured', $this->getFeatured());

    // Multiple keys
    $context = $context->merge([
        'categories' => $this->getCategories(),
        'tags' => $this->getTags(),
    ]);

    // Chained
    $context = $context
        ->with('hero', $this->getHeroData())
        ->with('testimonials', $this->getTestimonials());

    return $this->view->render('home', $context);
}

Dynamic Keys ​

Access dynamic keys (from context providers, etc.) via get() or array syntax:

php
public function handle(TemplateContext $context): ?string
{
    // Via get() method
    $pagination = $context->get('pagination');
    $customData = $context->get('custom_key', 'default');

    // Via ArrayAccess
    $pagination = $context['pagination'];

    // Check existence
    if ($context->has('pagination')) {
        // ...
    }

    return $this->view->render('archive', $context);
}

Typed DTOs ​

For complex page data, use typed DTOs with withDto():

php
use Studiometa\Foehn\Contracts\Arrayable;
use Studiometa\Foehn\Concerns\HasToArray;

final readonly class ProductPageData implements Arrayable
{
    use HasToArray;

    public function __construct(
        public PostCollection $related,
        public array $categories,
        public ?float $averageRating,
    ) {}
}

public function handle(TemplateContext $context): ?string
{
    $product = $context->post(Product::class);

    $pageData = new ProductPageData(
        related: $product->related(4),
        categories: $product->categories(),
        averageRating: $product->averageRating(),
    );

    // DTO properties are flattened for Twig access
    $context = $context->withDto($pageData);

    // Later, retrieve the typed DTO if needed
    $data = $context->dto(ProductPageData::class);
    $data->averageRating; // ?float with IDE support

    return $this->view->render('single-product', $context);
}

In Twig, DTO properties are directly accessible:

twig
<h1>{{ post.title }}</h1>
<p>Rating: {{ averageRating }}</p>

{% for item in related %}
    <a href="{{ item.link }}">{{ item.title }}</a>
{% endfor %}

Template Matching ​

WordPress Template Hierarchy ​

Template names follow the WordPress template hierarchy:

php
// Home page
#[AsTemplateController('home')]
final class HomeController implements TemplateControllerInterface {}

// Front page
#[AsTemplateController('front-page')]
final class FrontPageController implements TemplateControllerInterface {}

// Single post
#[AsTemplateController('single')]
final class SingleController implements TemplateControllerInterface {}

// Single product
#[AsTemplateController('single-product')]
final class SingleProductController implements TemplateControllerInterface {}

// Archive
#[AsTemplateController('archive')]
final class ArchiveController implements TemplateControllerInterface {}

// Category archive
#[AsTemplateController('category')]
final class CategoryController implements TemplateControllerInterface {}

// 404 page
#[AsTemplateController('404')]
final class NotFoundController implements TemplateControllerInterface {}

Wildcard Patterns ​

php
// All single templates
#[AsTemplateController('single-*')]
final class AllSinglesController implements TemplateControllerInterface {}

// All archive templates
#[AsTemplateController('archive-*')]
final class AllArchivesController implements TemplateControllerInterface {}

Multiple Templates ​

php
#[AsTemplateController(['home', 'front-page'])]
final class HomeController implements TemplateControllerInterface {}

Returning Null ​

Return null to let WordPress handle the template normally:

php
#[AsTemplateController('single')]
final class SingleController implements TemplateControllerInterface
{
    public function handle(TemplateContext $context): ?string
    {
        $post = $context->post;

        // Only handle published posts
        if ($post?->post_status !== 'publish') {
            return null; // Let WordPress handle it
        }

        return $this->view->render('single', $context);
    }
}

Real-World Examples ​

Home Page Controller ​

php
<?php

namespace App\Controllers;

use Studiometa\Foehn\Attributes\AsTemplateController;
use Studiometa\Foehn\Contracts\TemplateControllerInterface;
use Studiometa\Foehn\Contracts\ViewEngineInterface;
use Studiometa\Foehn\Views\TemplateContext;

#[AsTemplateController('front-page')]
final class HomeController implements TemplateControllerInterface
{
    public function __construct(
        private readonly ViewEngineInterface $view,
    ) {}

    public function handle(TemplateContext $context): ?string
    {
        $context = $context
            ->with('hero', $this->getHeroData())
            ->with('featured_products', $this->getFeaturedProducts())
            ->with('testimonials', $this->getTestimonials())
            ->with('latest_posts', $this->getLatestPosts());

        return $this->view->render('pages/home', $context);
    }

    private function getHeroData(): array
    {
        return [
            'title' => get_field('hero_title', 'option'),
            'subtitle' => get_field('hero_subtitle', 'option'),
            'cta' => get_field('hero_cta', 'option'),
        ];
    }

    private function getFeaturedProducts(): array
    {
        return \Timber\Timber::get_posts([
            'post_type' => 'product',
            'posts_per_page' => 4,
            'meta_key' => 'featured',
            'meta_value' => '1',
        ]);
    }

    private function getTestimonials(): array
    {
        return \Timber\Timber::get_posts([
            'post_type' => 'testimonial',
            'posts_per_page' => 6,
            'orderby' => 'rand',
        ]);
    }

    private function getLatestPosts(): array
    {
        return \Timber\Timber::get_posts([
            'post_type' => 'post',
            'posts_per_page' => 3,
        ]);
    }
}

Archive Controller with Pagination ​

php
<?php

namespace App\Controllers;

use Studiometa\Foehn\Attributes\AsTemplateController;
use Studiometa\Foehn\Contracts\TemplateControllerInterface;
use Studiometa\Foehn\Contracts\ViewEngineInterface;
use Studiometa\Foehn\Views\TemplateContext;

#[AsTemplateController(['archive', 'archive-*', 'category', 'tag'])]
final class ArchiveController implements TemplateControllerInterface
{
    public function __construct(
        private readonly ViewEngineInterface $view,
    ) {}

    public function handle(TemplateContext $context): ?string
    {
        if ($context->posts) {
            $context = $context->with('pagination', $context->posts->pagination([
                'mid_size' => 2,
                'end_size' => 1,
            ]));
        }

        $context = $context
            ->with('archive_title', get_the_archive_title())
            ->with('archive_description', get_the_archive_description());

        return $this->view->render('pages/archive', $context);
    }
}

Search Controller ​

php
<?php

namespace App\Controllers;

use Studiometa\Foehn\Attributes\AsTemplateController;
use Studiometa\Foehn\Contracts\TemplateControllerInterface;
use Studiometa\Foehn\Contracts\ViewEngineInterface;
use Studiometa\Foehn\Helpers\WP;
use Studiometa\Foehn\Views\TemplateContext;

#[AsTemplateController('search')]
final class SearchController implements TemplateControllerInterface
{
    public function __construct(
        private readonly ViewEngineInterface $view,
    ) {}

    public function handle(TemplateContext $context): ?string
    {
        $context = $context
            ->with('search_query', get_search_query())
            ->with('found_posts', WP::query()->found_posts);

        if ($context->posts) {
            $context = $context->with('pagination', $context->posts->pagination());
        }

        return $this->view->render('pages/search', $context);
    }
}

Context Providers vs Template Controllers ​

FeatureContext ProviderTemplate Controller
PurposeAdd data to existing contextFull control over rendering
ReturnsModified context arrayRendered HTML string or null
MultipleCan stack multiple providersOne controller per template
Use caseShared data (menus, etc.)Complex page logic

Use Context Providers for:

  • Adding shared data to multiple templates
  • Injecting navigation, footer data
  • Simple context enrichment

Use Template Controllers for:

  • Complex business logic
  • Custom template resolution
  • Full rendering control

Organizing Controllers ​

app/Controllers/
├── HomeController.php
├── SingleController.php
├── ArchiveController.php
├── ProductController.php
├── SearchController.php
└── NotFoundController.php

See Also ​

Released under the MIT License.