A maintainable WordPress theme separates request and data preparation from presentation. Timber provides WordPress-aware objects in PHP and renders them with Twig templates, which makes that boundary easier to see. The result is not a new application framework around WordPress; it is a cleaner way to follow the template hierarchy while keeping queries, decisions, and markup in appropriate places.
An MVC-inspired boundary, not strict MVC
Traditional MVC terminology can help describe responsibilities, but WordPress does not become a strict MVC framework when Timber is installed. WordPress owns routing, the main query, the template hierarchy, hooks, permissions, content storage, and much of the application lifecycle. Timber adapts WordPress data for Twig and gives the theme a clear rendering layer.
In this arrangement, WordPress and domain code provide the model-like data. The selected PHP template acts as a small controller or presenter: it gathers what the view needs and chooses a template. Twig is the view. The useful result is the boundary, not whether every class fits a textbook label.
This approach also respects familiar WordPress debugging. If a single post looks wrong, begin with the template hierarchy, inspect single.php, look at the context passed to Timber, and then inspect the selected Twig file. Each step has a defined role.
A practical project structure
The following tree illustrates responsibilities. It is a structure example rather than a complete drop-in theme:
my-theme/
├── functions.php
├── single.php
├── archive.php
├── src/
│ ├── Context.php
│ └── ViewModels/
│ └── ArticleView.php
└── views/
├── base.twig
├── single.twig
├── archive.twig
└── components/
├── article-card.twig
└── pagination.twig
Timber v2 looks in the theme’s views directory by default, checking the child theme before falling back to the parent theme. A project can configure other locations and Twig namespaces, but a conventional directory reduces surprise. Keep reusable fragments in named component files and use Twig inheritance for the shared document structure.
functions.php should bootstrap the theme, register supported features, and load focused classes. It should not become a single file containing every query, filter, REST endpoint, and formatting decision. A small autoloaded namespace under src makes responsibilities easier to find and test.
The WordPress PHP template filenames remain important. Timber does not replace single.php with a Twig route. WordPress selects the PHP entry point first; that entry point then asks Timber to render a view. Keep fallback PHP templates valid enough to report a clear dependency problem if Timber is unavailable.
Build the context in PHP
The context is the explicit contract between PHP and Twig. Start from Timber’s shared context, add the current post, and provide any page-specific values under meaningful keys:
<?php
$context = Timber\Timber::context();
$context['post'] = Timber\Timber::get_post();
$context['related_posts'] = ArticleView::related_posts(
$context['post']->ID,
3
);
Timber\Timber::render( 'single.twig', $context );
Timber\Timber::context() includes the global values Timber prepares for themes, such as the site and current request context. Timber\Timber::get_post() turns the current WordPress post into the appropriate Timber post object. Your code can then add data needed only by this view.
Use descriptive keys rather than exposing an unrelated service container or a collection of globals to Twig. If related_posts appears in the contract, another developer can locate the PHP that creates it. If the template calls arbitrary static methods to fetch data, the dependency is hidden inside presentation.
Shared view data, such as a primary menu or a small site-wide setting, can be added through Timber’s context filters. Query-specific data belongs close to the PHP entry point or a focused view model. Avoid running the same expensive query in a global context filter when only one template uses it.
Render presentation with Twig
Twig expresses markup, inheritance, loops, and simple presentation conditions without mixing them with database access. A single-post view might extend a shared layout and render fields already supplied by PHP:
{% extends 'base.twig' %}
{% block content %}
<article class="article">
<h1>{{ post.title|esc_html }}</h1>
<p class="article__meta">
Updated {{ post.modified_date|esc_html }}
</p>
<div class="article__content">
{{ post.content|wp_kses_post }}
</div>
</article>
{% if related_posts %}
<section aria-labelledby="related-title">
<h2 id="related-title">Related articles</h2>
{% for item in related_posts %}
{% include 'components/article-card.twig' with { post: item } only %}
{% endfor %}
</section>
{% endif %}
{% endblock %}
The example deliberately keeps decisions close to presentation: whether to show a related section and how to compose a card belong in the view. Deciding which posts are related, enforcing permissions, or calculating commercial rules belongs in PHP. The only keyword on the include narrows the component contract, which prevents it from silently depending on unrelated parent variables.
Template inheritance can keep the document shell, navigation, footer, and standard blocks consistent. Components should receive the smallest useful object or value set. A card that accepts a post and an optional variant is easier to reuse than one that assumes a particular archive’s complete context.
Where business logic belongs
A theme should control presentation. Behaviour that must survive a theme switch belongs in a plugin, MU plugin, or application service loaded outside the theme. Examples include custom post types, payment rules, data synchronisation, access policy, API endpoints, and scheduled processing. Theme code can request and present their results without owning the underlying business rule.
| Responsibility | Typical home | Reason |
|---|---|---|
| Register a durable content type | Plugin or MU plugin | The content should remain valid after a theme switch |
| Build data for one article view | Template PHP or view model | It describes what that presentation needs |
| Render a card or layout | Twig component | It is presentation with a defined input |
| Enforce permissions or pricing | Plugin service and WordPress hooks | The rule must not depend on the active design |
| Format a purely visual label | Twig or a small presentation helper | The choice belongs to the interface |
Hooks remain part of this architecture. A plugin can expose filtered data, and the theme can add presentation context through a Timber filter. Constructor-heavy classes that register every hook immediately are harder to test; focused registration methods and injected collaborators make side effects clearer.
Keep queries out of Twig even when a function makes them technically possible. Fetching data in the view hides performance costs and makes caching harder to reason about. Prepare a collection once, name it in the context, and let the template iterate over it.
Test the contract and escape for context
Test PHP units that contain decisions independently from the rendered page. For the view boundary, test that a known request builds required context keys and that critical templates render expected headings, links, empty states, and accessible labels. Add browser checks for responsive components and user journeys whose behaviour depends on JavaScript.
Escaping requires explicit attention. Timber’s Twig environment does not escape the output of standard tags by default. Escape plain text with esc_html, URLs with esc_url, attribute values with esc_attr, and post-like HTML with wp_kses_post. Validate values on input and escape them again for the exact output context.
<a href="{{ item.link|esc_url }}"
aria-label="{{ item.label|esc_attr }}">
{{ item.label|esc_html }}
</a>
<div class="prose">
{{ post.content|wp_kses_post }}
</div>
Allowing markup is different from trusting arbitrary markup. Use wp_kses_post when content should retain WordPress’s permitted post HTML. Bypassing escaping is appropriate only for a value whose trusted and sanitised origin is understood at that output point; document that boundary rather than spreading an unrestricted convention through templates.
Teams that deliberately prefer Twig autoescape can enable it through the documented environment options filter:
add_filter(
'timber/twig/environment/options',
function ( array $options ): array {
$options['autoescape'] = 'html';
return $options;
}
);
Make that choice at project level and test existing templates before enabling it. Code that already escapes values can produce different output when a second escaping policy is introduced. Even with autoescape, URL, attribute, JavaScript, and permitted-HTML contexts still require deliberate handling.
Migrating an older Timber v1 theme
Treat a v1-to-v2 change as a migration, not a search-and-replace performed directly on production. Upgrade dependencies on a branch or staging copy, read the v2 upgrade guide for the versions in use, and test every template type represented by the site.
| Older pattern | Timber v2 direction | Migration check |
|---|---|---|
| Global class calls | Namespaced Timber\Timber APIs |
Imports, bootstrap, static calls, and type references |
| Legacy context retrieval | Timber\Timber::context() |
Global keys and custom context filters |
| Direct legacy post construction | Timber\Timber::get_post() and factories |
Custom post classes and returned object types |
| Assumed Twig output safety | Explicit contextual escaping or tested autoescape | Text, URLs, attributes, and allowed post HTML |
| Old method/property behaviour | Current v2 object APIs | Archives, images, menus, pagination, and custom fields |
Inventory every PHP and Twig file before changing dependencies. Enable error reporting on staging, exercise singular pages, archives, search, 404, pagination, menus, images, comments, and custom post types, then compare markup and behaviour. Clear compiled template and page caches between versions so an old render does not hide a problem.
The most useful result of the migration is a documented boundary: WordPress selects the request, PHP prepares a small context, Twig renders explicit inputs, durable behaviour lives outside the theme, and every output has a known escaping rule.
Build around clear responsibilities
Keep WordPress logic readable and templates focused
Use the current Timber v2 documentation as the source for APIs, template locations, escaping, and migration details before applying the pattern to a production theme.
Well, that’s ok if you are developing a theme for yourself, but then if this is a publicly distributed template, and you are the guy who will have to deal with it and develop the actual website, you might have an hard time when trying to add some custom functionality.
you will have to do quite a bit of reverse engineering in order to understand how each class and method are set up (not to mention adorable abstractions..), and how would you be able to add some functionality which is needed in your development.
and this will be quite an hassle even if the theme is well coded and it has support for smart hooks and actions, and overridable functions (which we all know it’s not always granted..)
while surely less elegant, the “old way” would instead allow for a quick understanding of each page functionalities, so that if you want to change or add something to single.php, you just have to go there and read/edit that code, or eventually check function.php if you bump into a custom function and you don’t know exactly how it works.
but then obviously, if you are developing a theme which will be used by you to develop websites that then will be maintained and improved by yourself, then the Object Oriented approach is the cleanest and easiest to maintain, especially if you work within a team of developers.
Thanks for sharing this OOB approach. I just retired from IT Management after 30 years and am still learning WP. One of the most important aspects of all coding, designing, and creating projects is to separate the building blocks for minimizing maintenance in the future and for having a well structured plan that others can share the same methodology. And, you’ve done this. Whether a person chooses to create their own theme or not, this is a very well explained approach that illustrates a well manageable path.