Jump to content

How Twig can improve your template structure


MoritzLost
 Share

Recommended Posts

I've written before about how to use Twig with ProcessWire (see my tutorials on integrating Twig into ProcessWire and extending Twig with custom functionality for ProcessWire). But those posts don't really talk about why I like to use Twig instead of plain PHP templates.

For me, this comes down to one killer feature that I'm going to talk about below. But first, let's look at some of the more commonly mentioned advantages of Twig and why I don't actually think they're all that important in the context of ProcessWire:

  • The syntax is nicer. While I personally agree with this, it's entirely subjective (and familiarity is comforting while trying something new is scary).
  • Autoescaping provides security by default. This is true to a degree, but most ProcessWire projects (at least for me) aren't the type of expansive community-driven sites with lots of user-generated content where this would be most relevant. Most of my ProcessWire projects so far have featured a few trusted editors managing content, where you don't really need autoescaping for every template to make sure nobody slips in some malicious code.
  • Twig forces separation of concerns between logic and presentation. Again, this is true, but not relevant to most ProcessWire projects. Most of my ProcessWire projects (and, judging by the showcase, most ProcessWire projects period) are mostly classic brochure sites without a lot of interactivity or app-like behaviour. Those projects are 99% presentation with only some small snippets of logic in between, so separating the two isn't really an issue.

With that out of the way, let's talk about the killer feature that makes Twig essential to my work: Inheritance and block-based overwrites. To explain why this is important, I'll start out with a basic template for a header component in PHP and see how it can handle additional content being added to it. Then I'll write the same component in Twig for comparison.

If you need a general guide on template inheritance in Twig, read this first: https://twig.symfony.com/doc/3.x/tags/extends.html

The reusable header template

Here's our basic reusable header template written in PHP:

<header class="header">
    <h1 class="header__headline"><?= $page->title ?></h1>
    <?php if ($page->subline): ?>
        <p class="header__subline"><?= $page->subline ?></p>
    <?php endif; ?>
    <?php if ($page->image) echo wireRenderFile('inc/responsive-image.php', ['image' => $page->image]) ?>
</header>

Sidenote, I'll use wireRenderFile to keep the examples brief, you could also write the image tag inline here.

The header component may be included in a page template like this:

<?= wireRenderFile('inc/header.php') ?>

You have two options for where to do this. Option one is to include this template in every template that needs it (templates/home.php, templates/project.php, templates/news.php). Option two is to use the appendTemplateFile setting to keep the basic page layout in a shared template file that's always included at the end of the request (_main.php).

Option one allows you to pass the template different variables depending on context, but it also means you've already started with the code duplication. Option two is probably the more common approach, but with this option you can only pass it one set of variables – those variables might be overwritten by the page template, but this will also lead to some problems as you'll see shortly.

Let's introduce our first change request, one particular page needs to display a video instead of an image. No problem, we can just check if the page has a video field and display it conditionally:

if ($page->video) {
    wireRenderFile('inc/video.php', ['video' => $page->video]);
} elseif ($page->image) {
    wireRenderFile('inc/responsive-image.php', ['image' => $page->image]);
}

This still works fine. But crucially, the logic for the video header is now part of the header template, not part of the template for the page with video headers. This means that every time I want to edit the header template, this little piece of conditional logic is something I have to deal with. But that's fine, multiple pages might need a video header, so having this switch in the header template is acceptable.

But then another change request come in: In the page template for some kind of project page, instead of the image, we want to display a list of project data coming from a separate project_data field. Again, we can adjust the template:

if ($page->project_data) {
    wireRenderFile('inc/project-data.php', ['data' => $page->project_data]);
} elseif ($page->video) {
    wireRenderFile('inc/video.php', ['video' => $page->video]);
} elseif ($page->image) {
    wireRenderFile('inc/responsive-image.php', ['image' => $page->image]);
}

But now some display logic that's specific to one template is part of the global header template, not part of the project.php. This trend will continue: every custom feature required for the header of any page template will inflate the header.php file, and every adjustment requires reading all of it and making sure my change doesn't break any of the other features. This is unsustainable and inherently unscalable.

Another example, what if a specific page has both the video and the image fields, but I want to display the image instead of the video? Currently, this is not possible. Now I have to build in some kind of switch:

$preferImage = $preferImage ?? false;
if ($page->project_data) {
    wireRenderFile('inc/project-data.php', ['data' => $page->project_data]);
} elseif ($page->video && !$preferImage) {
    wireRenderFile('inc/video.php', ['video' => $page->video]);
} elseif ($page->image) {
    wireRenderFile('inc/responsive-image.php', ['image' => $page->image]);
}

Again, this solution doesn't scale. Did you notice the subtle bug in there? The noise to signal ratio is becoming worse with every feature.

Now you're probably thinking that you would approach those change requests in a different way. Let's look at some of the possible solutions to those problems.

Lots of variables

You can solve this to a degree by using lots of variables to control what you're template is doing. If we're using a shared _main.php template file that includes the inc/header.php template, the project-specific template (e.g. templates/project.php) is loaded first. So those templates can set some variables that change the content and behaviour of the header component. For example, say you want to do keep the template for the project data block in your project.php so it's easy to find. Let's go back to the original header template and introduce an optional variable that can be used to replace the image with something else:

<?= $headerImageContent ?? wireRenderFile('inc/responsive-image.php', ['image' => $page->image]);

Now you can set the $headerImageContent variable in your project.php and it will replace the image.

But what if I want both the normal image (without duplicating code) AND some custom content? No problem, add even more variables:

<?= $headerImageBeforeContent ?? '' ?>
<?= $headerImageContent ?? wireRenderFile('inc/responsive-image.php', ['image' => $page->image]);
<?= $headerImageAfterContent ?? '' ?>

Now repeat that for every part of the header template which might need to be adjusted for some of the page templates (hint: it's all of them). You end up with a template that uses tons of variables, the signal to noise ratio becomes abhorrent. Throw in the fact that those variables are all unscoped, so there's no way to tell where they are being set or overwritten, and variable names have be very specific to avoid collisions. All of this might make sense to you the day you've written it, but what about your colleague that hasn't touched this project yet? What about yourself in six months?

Make templates more granular

Another solution is to make the templates more granular. I've started this trend above by using wireRenderFile to put little isolated template parts into their own dedicated template – for example, to display a single responsive image or an HTML5 video player. In the same grain, you can split up the header.php into multiple smaller template to mix and match and include include those you want to in each specific context. But this has downsides as well:

  • You end up with a fractal nightmare, a deluge of templates with increasing granularity and decreasing utility, just to be able to include those smaller templates separately from each other. Cohesion and readability is reduced, and there's no way from directory structure alone to tell which templates go together in what ways.
  • Splitting an existing template into two smaller templates is not backwards compatible – you have to make an adjustment in every place the original template was included. Or you keep the original template but change it to just include the two new templates. I said fractal nightmare already, didn't I?

Duplicate code

You can, of course, just keep separate header templates for each page type. But then you're duplicating the common parts of those templates all over again, and changing those means you have to touch a lot of separate files – definitely not DRY.


Most real-life solutions will include a mix of the three approaches. I tried to be fair and write the templates in the leanest and cleanest way possible, but things still got out of hand quickly. Now let's look at the same component written in Twig:

Resuable components in Twig

Here's the basic header template but written in Twig:

{# components/header.twig #}

<header class="header">
    <h1 class="header__headline">{{ page.title }}</h1>
    {% if page.subline %}
        <p class="header__subline">{{ page.subline }}</p>
    {% endif %}
    {% block header_image %}
        {% if page.image %}
            {{ include('components/responsive-image', { image: page.image, }) }}
        {% endif %}
    {% endblock %}
</header>

One important difference is the block tag defining the header_image block. So far we don't need that, but it will become important in a second.

For the page templates, it's common to have a base template that all other templates inherit from:

{# html.twig #}

<!doctype html>
<html lang="en" dir="ltr">
<head>
    <title>{%- block title -%}{%- endblock -%}</title>
    {% block seo %}
        {{ include('components/seo', with_context = false) }}
    {% endblock %}
</head>
<body>
    {% block header %}
        {{ include('components/header') }}
    {% endblock %}

    {% block content %}{% endblock %}

    {% block footer %}
        {{ include('components/footer') }}
    {% endblock %}
</body>

The base template defines some blocks and includes some default components (seo, header, footer). Now the template for the project page just inherits this:

{# project.twig #}

{% extends 'html' %}

With the PHP template, things got difficult once we wanted to overwrite part of the header template with some content specific to one page template. This is where the header_image block comes in handy:

{# project.twig #}

{% extends 'html' %}

{% block header %}
    {% embed "components/header" %}
        {% block header_image %}
            {# project data template … #}
        {% endblock %}
    {% endembed %}
{% endblock %}

Now the project.twig extends the base html.twig template and overwrites the header block. Then it includes the components/header template and overwrites only the header_image block while keeping the rest.

This approach has some major advantages over the plain PHP template:

  • All the code for the project template is in one place – to see what's special about this particular page in comparison to the base template, I just have to look at one template.
  • I didn't have to repeat any of the header template code, so I can still change the header in a central place.
  • The components/header template stays small and manageable, it doesn't know or care what other templates extend it and which parts get overwritten where.

As a sidenote, some people may not like the embed syntax. Another approach would be to once again create a custom header template for the project template. But this time, we don't need to repeat any code because we can use inheritance:

{# components/project-header.twig #}

{% extends "components/header" %}

{% block header_image %}
    {# project data template … #}
{% endblock %}

I prefer the embed approach because it keeps all the related code together. But both approaches allow for full flexibility with no code duplication.


Now what if you want to change other parts of the components/header.twig template in an extending template? In this case, you can always add more blocks:

{# components/header.twig #}

{% block header_headline %}
    <h1 class="header__headline">{{ page.title }}</h1>
{% endblock %}

Adding blocks doesn't change anything about the base template, so it's 100% backwards-compatible. You can always add more blocks without ever having to worry about breaking any existing templates or introducing bugs.

Another challenge for the PHP template was to add some additional content to a part of the header template while still keeping the default content. Let's say we want to display a publication date above the headline in a news template, but keep the headline as is. No problem:

{# project.twig #}

{% block header_headline %}
    <time>{{ entry.published_date }}<time>
    {{ parent() }}
{% endblock %}

The parent() function returns the content of the block in the base template, so you can extend a block without overwriting it completely.

Conclusion

You can solve all the challenges I posed here in PHP. Most solutions will include a combination of the three approaches mentioned above (making templates more granular, using lots of variables and duplicating code). And a well thought-out mix of those approaches can work reasonably well. The problem is that while those solutions improve reusability and scalability, they usually require lots of boilerplate code and unscoped variables. This reduces the readability and makes the system harder to modify, while making it easier for bugs to creep in. Again, there are solutions for those problems that introduce other problems until the solutions cancel each other out in trade-offs.

To me, Twig is a great alternative that requires fewer trade-offs. It allows you to achieve complete freedom and flexibility in your templates all while keeping your templates DRY and keeping code that belongs together in a single file. On top of that, Twig uses a nice, readable syntax (warning: personal opinion) and provides a lot of utility methods and other features to improve your template structure.

Some notable caveats to all of this:

  • All of the discussed problems are about scaling a project to a larger scope or team size. For small projects that will never need to scale in this way, this doesn't really matter.
  • ProcessWire's built-in markup regions seem to tackle a lot of the same problems I mentioned in this post. Can't really speak for it as I haven't tried it yet.

If this all sounds interesting to you and you want to learn more, you can check out my tutorials on integrating Twig into ProcessWire and extending Twig with custom functionality for ProcessWire.

  • Like 12
Link to comment
Share on other sites

I kbow it has not directly to do with the topic but thanks to your code examples I finally stumbled upon a solution to include php files with arguments!

Like this:

<? wireRenderFile('inc/project-data.php', ['data' => $page->project_data]); ?>

I've been using Processwire for years and always looked for a method like that. Crazy...
I used PHP functions in that case - until now. It's time to update my projects ?

  • Like 1
Link to comment
Share on other sites

Actually this is a really great starting-point for Twig in ProcessWire.

While I still stay with plain PHP in my projects I really like the Twig syntax due to Nunjucks/Liquid in 11ty.

Maybe you could update your processwire.dev with this point - as this is a thing some people really look for.
Maybe even @bernhard could write a guest-post there with something about latte in plain-vanilla-pw and when it's coming RockFrontend.

For myself so far I decided to look more into this after recent projects are done.
Especially twig (as I know already the syntax).

  • Like 2
Link to comment
Share on other sites

On 7/30/2022 at 12:34 AM, wbmnfktr said:

Maybe you could update your processwire.dev with this point - as this is a thing some people really look for.

@wbmnfktr I thought about putting this post on processwire.dev, but it somehow feels like it doesn't belong there. Not sure why – maybe it doesn't provide as much a benefit as (I feel) the other tutorials there do. I've had this topic on my mind because I've been repeatedly reading that Twig doesn't do anything that you can't do in plain PHP. I obviously disagree and needed to get this out of my head and onto a page so I can link to it when it comes up ? But if you don't need convincing to use Twig, the post doesn't provide anything the other two tutorials don't already cover. Maybe I'll integrate this post in processwire.dev at some point …

  • Like 1
  • Thanks 1
Link to comment
Share on other sites

On 8/1/2022 at 10:00 AM, MoritzLost said:

Maybe I'll integrate this post in processwire.dev at some point …

If you are OK with linking to posts of this forum, you could just simply include a line or two like: "You can read more about realted topics at 'here' and 'there' etc..."

Edited by szabesz
typo
  • Like 2
Link to comment
Share on other sites

17 hours ago, MoritzLost said:

I thought about putting this post on processwire.dev, but

Well... in the past I looked up ways to get Twig up and running in ProcessWire and what-else-where and had to look up so many sources, details, guides and whatever that my urge to get it up and running diminished due to each and every step. Eventually I got it up and running, played with it, yet it was never backed from an "official" or at least "experienced" source. So I never trusted my setup.

If I ever had a reliable source in the PW-Community that wrote, talked, spoke about it... I might have invested more time. Yet... there was noone. For whatever reason. Sure there were guides on modules, but not in detail. Or at least not for starters or dummies like me.

You on the other hand are experienced in Twig, ProcessWire and therefore would have been a "source of truth" in some kind - if I ever would have had the chance to find a guide by you, I probably had way more fun with Twig and a ProcessWire/Twig combo.

I don't wanna talk you into writing such a guide on your site or moving this piece of content as it is onto your site, but... even though it might be unpolished, not that detailed as other guides on your site... it would, could, should, might, whatever... drive people into trying it in a much easier way than struggling through lots of posts, tutorials or whatever. Maybe even Twig-users could find ProcessWire as their PHP-framework in this way.

Who knows?

Sure there might be more details needed but you know those details and could write at least an outlined article on how to get Twig up and running in ProcessWire. (Sure I know you work fulltime in an agency, with Craft CMS, ... ) Yet... you could.

You know ProcessWire way better than me, and Twig as well, so... yes... I'd like to follow your guide or tutorial to get this up and running. You don't have to convince for me anything. My curiosity is already there - like it's by most of us here.

On the other hand... I understand your feelings and thoughts about "it doesn't match the existing content" but... hell... it doesn't match most of the time. Wherever you look, whatever you write. Some posts are way more detailed while others are just basics. Yet... It helps. Probably.

To make it short:

Your inital post already convinced more to look into Twig in ProcessWire than any other post around - as it told me it could be used in a professional way. I thought about it in the past but maybe I gave up too early.

So yeah... those were my almost last two cents on this topic.

  • Thanks 1
Link to comment
Share on other sites

@wbmnfktr FYI I've just added support for Twig in RockFrontend

<?= $rockfrontend->render("sections/header.twig") ?>

It will also be easy to add any other template engine:

// put this in site/ready.php
// it will add support for rendering files with .foo extension
// usage: echo $rockfrontend->render("sections/demo.foo")
$wire->addHook("RockFrontend::renderFileFoo", function($event) {
  $file = $event->arguments(0);
  // implement the renderer here
  $event->return = "Rendering .foo-file $file";
});

I swear I'll release it very soon ?

  • Like 2
Link to comment
Share on other sites

13 hours ago, wbmnfktr said:

I don't wanna talk you into writing such a guide on your site or moving this piece of content as it is onto your site, but... even though it might be unpolished, not that detailed as other guides on your site... it would, could, should, might, whatever... drive people into trying it in a much easier way than struggling through lots of posts, tutorials or whatever. Maybe even Twig-users could find ProcessWire as their PHP-framework in this way.

@wbmnfktr Fair enough! For now I've added a disclaimer with a link to this post to the Twig setup tutorial (just below the introduction) so people looking for reasons to use Twig can understand my reasoning. Maybe in the future I'll rework this article a bit and put it all on processwire.dev. Probably not a good idea to just copy it over as is, Google will think I'm a spambot ?

13 hours ago, wbmnfktr said:

Sure there might be more details needed but you know those details and could write at least an outlined article on how to get Twig up and running in ProcessWire. (Sure I know you work fulltime in an agency, with Craft CMS, ... ) Yet... you could.

That tutorial already exists on processwire.dev though, see the links in my initial post (part onepart two). Those two tutorials go through the complete Twig setup and a basic structure for Twig templates that should fit most use-cases and integrates nicely into the existing PHP templates, as well as some pointers on extending it with custom functionality. Or is there something that you feel is missing there? ?

13 hours ago, wbmnfktr said:

If I ever had a reliable source in the PW-Community that wrote, talked, spoke about it... I might have invested more time. Yet... there was noone. For whatever reason. Sure there were guides on modules, but not in detail. Or at least not for starters or dummies like me.

You on the other hand are experienced in Twig, ProcessWire and therefore would have been a "source of truth" in some kind - if I ever would have had the chance to find a guide by you, I probably had way more fun with Twig and a ProcessWire/Twig combo.

Well, in the end my setup is just one way to integrate Twig into ProcessWire, and there are a couple of different options (like Bernhards upcoming module). And there are definitely some edge-cases that arise from Twig not being "officially" integrated into ProcessWire – like the translation system that can't detect translatable strings in Twig. There are workarounds for those, but you often have to invest a bit more time to get things working. I think Twig is a great benefit and wouldn't want to miss it in any of my projects. But it comes with some strings attached, and the setup might break in unexpected ways with every new ProcessWire update. So a community-provided module might be the 'safer' option if there's an active maintainer behind it who will keep everything up to date and working with new PW versions, and do all the Twig setup and config 'under the hood'.

  • Like 2
Link to comment
Share on other sites

16 hours ago, MoritzLost said:

I think Twig is a great benefit and wouldn't want to miss it in any of my projects. But it comes with some strings attached, and the setup might break in unexpected ways with every new ProcessWire update. So a community-provided module might be the 'safer' option if there's an active maintainer behind it who will keep everything up to date and working with new PW versions, and do all the Twig setup and config 'under the hood'.

Hey @MoritzLost that statement confuses me a little bit. What I'm doing is simply having a main markup file in PHP (_main.php) and from there I render template files (LATTE, Twig...). That's a super simple setup and I can't see any reason why this should break with any future PW update.

All the other template engine modules for PW seem to use a different approach, more or less completely replacing the rendering strategy of PW if I understood that correctly? Do they? I don't really understand what's going on there to be honest, as I've only been using my simple approach which has worked extremely well. Though I might be missing something important here? There might be a reason why a more complicated approach is preferable over my slimmed down one?

I have the same problem with translating strings, but there are easy workarounds (either manually or there is also one built into $rockfrontend->x('my_translatable_string')).

Thx for your insights!

  • Like 1
Link to comment
Share on other sites

5 hours ago, bernhard said:

Hey @MoritzLost that statement confuses me a little bit. What I'm doing is simply having a main markup file in PHP (_main.php) and from there I render template files (LATTE, Twig...). That's a super simple setup and I can't see any reason why this should break with any future PW update.

@bernhard You're right, a simple setup like that is unlikely to break. That's also close to the setup my tutorials recommend and that I'm using for all ProcessWire projects.

What I had in mind are smaller problems / incompatibilities that crop up between PW and Twig from time to time. We recently did a round of updates to 3.0.200 and ended up with some exceptions because of the way we were accessing fields that may or may not exist on some pages. The way Twig tries to access object properties was causing some unusual errors. If you don't know both ProcessWire and Twig very well, this can be really hard to debug. If you're using an actively maintained plugin that comes with some integrated features, the developer will probably notice those problems as they come up and either fix them or provide guidance on how to avoid them. What I meant above was that with a custom setup, you can end up with a bug that you don't know how to fix and have nobody to turn to for help. But maybe those points apply regardless of how you include Twig, manually or through a plugin … in any case, I wasn't thinking about your upcoming module there, I'm sure it'll be a useful time-saver!

  • Thanks 1
Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
 Share

×
×
  • Create New...