DEV Community

Cover image for Lazily loading template data in PHP with Twig and Shoot
Erik Booij
Erik Booij

Posted on

Lazily loading template data in PHP with Twig and Shoot

Disclaimer: this is a rewrite (new and improved) of a post I've originally shared on my personal blog

If you've ever worked with a relatively large codebase with many nested templates, you have probably found yourself in a situation where you needed to either pass variables way down the template tree, pollute the global (template) scope with many variables only relevant to deeply nested templates, or find other creative solutions for injecting data into these sub templates (with Twig extensions for example).

At Coolblue we are using Twig for template rendering and we have definitely found ourselves in that position. Over the years we have tried numerous approaches for working around that problem, including but not limited to injecting fully prerendered views/templates into other templates. Nothing ever felt really clean, maintainable or futureproof, but fortunately there is now a solution that basically solves all of our problems in that area.

Shoot is a Twig extension built by Victor Welling, also a developer at Coolblue. What Shoot allows you to do is tie a presentation model to a template. The model defines and limits the variables that are in scope when rendering the template. These presentation models are pretty much plain old PHP objects extending Shoots PresentationModel, containing a protected field for every variable. Where the magic comes in though, is that these models can declare themselves as "having a presenter" by implementing the HasPresenterInterface. A presenter populates the fields in a model at runtime, by accessing its (optional) dependencies and a PSR-7 ServerRequest. Basically this means a template now fetches its own data.

Benefits

The benefits we're getting out of using Shoot over conventional handling of templates and template data:

  • Performance (data is only retrieved if the template is actually being rendered)
  • Way better maintainability
  • Improved testability
  • More isolation (no more leakage of template scope)
  • Increased template reuse

In the Shoot README we can find a clear overview of how the paradigm shifts from preloading all data to "lazily loading" the data, based on rendered templates:

+---------------+          +---------------+
|    Request    |          |    Request    |
+-------+-------+          +-------+-------+
        |                          |     +---------+
        |                          |     |         |
+-------v-------+          +-------v-----v-+     +-+-------------+
|   Load data   |          |  Render view  +----->   Load data   |
+-------+-------+          +-------+-------+     +---------------+
        |                          |
        |                          |
+-------v-------+          +-------v-------+
|  Render view  |          |   Response    |
+-------+-------+          +---------------+
        |
        |
+-------v-------+
|   Response    |
+---------------+

How to use Shoot

In order to actually connect your template and a presentation model, you simply add the {% model %} tag to the template pointing the fully qualified class name of your model:

{% model 'ShootDemo\\Presentation\\BlogPostModel' %}

<html lang="en">
<head>
    <title>{% if post_exists %}{{ post_title }}{% else %}404 - Not Found{% endif %}</title>
    <meta charset="UTF-8">
</head>
<body>
{% if post_exists %}
    <h1>{{ post_title }}</h1>
    {% for paragraph in post_content %}
        <p>{{ paragraph }}</p>
    {% endfor %}
{% else %}
    <h1>Post not found</h1>
    <p>
        This post could not be retrieved. Check out the other posts:
    </p>
{% endif %}

<a href="/?postId=1">Post 1</a> &nbsp; <a href="/?postId=2">Post 2</a>
</body>
</html>

As you can see we're pointing to the following model: ShootDemo\Presentation\BlogPostModel. There are three variables used in this template and these all need to be defined in the model.

class BlogPostModel extends PresentationModel
{
    /** @var string[] */
    protected $post_content = '';

    /** @var bool */
    protected $post_exists = false;

    /** @var string */
    protected $post_title = '';
}

This model now provides the variables with these values to the template, dynamically, without you needing to pass them in from the parent. This is a static model though, the post would never exist. We could step it up a notch and make it dynamic, based on the current request, by making the model implement the HasPresenterInterface:

class BlogPostModel extends PresentationModel implements HasPresenterInterface
{
    /** @var string[] */
    protected $post_content = '';

    /** @var bool */
    protected $post_exists = false;

    /** @var string */
    protected $post_title = '';

    /**
     * @return string
     */
    public function getPresenterName(): string
    {
        return BlogPostPresenter::class;
    }
}

The interface defines a single method, getPresenterName(): string. That method should return the alias by which the actual presenter can be retrieved from a PSR-11 compliant container. Since we register our presenters by their fully qualified class name, that's also what we return here. It gives the added benefit of easy traversal in your IDE. The presenter might look something like this:

class BlogPostPresenter implements PresenterInterface
{
    /** @var BlogPostRepositoryInterface */
    private $blogPostRepository;

    /**
     * @param BlogPostRepositoryInterface $blogPostRepository
     */
    public function __construct(BlogPostRepositoryInterface $blogPostRepository)
    {
        $this->blogPostRepository = $blogPostRepository;
    }

    /**
     * @param ServerRequestInterface $request
     * @param PresentationModel      $presentationModel
     *
     * @return PresentationModel
     */
    public function present(ServerRequestInterface $request, PresentationModel $presentationModel): PresentationModel
    {
        $postId = (int)($request->getQueryParams()['postId'] ?? -1);

        try {
            $blogPost = $this->blogPostRepository->fetchBlogPost($postId);
        } catch (UnableToFetchBlogPostException $ex) {
            // Unable to load blog post return presentation model, explicitly setting post_exists to false
            return $presentationModel->withVariables([
                'post_exists' => false,
            ]);
        }

        $variables = [
            'post_content' => $blogPost->content(),
            'post_exists'  => true,
            'post_title'   => $blogPost->title(),
        ];

        return $presentationModel->withVariables($variables);
    }
}

This is it, your template now automatically gets populated with the relevant values for that request. Whenever the template is rendered, it will load its model, thereby automatically calling the presenter to update the properties of the model. No more trying to determine what your template might need in your controller/request handler.

As you can see the presenter can have its own dependencies like the $blogPostRepository, by configuring it in your DI-container. It also gets access to the request in the ->present() method. The second argument it receives is the model we've just defined. You're now free to write any logic you desire in this method, as long as you return an instance of PresentationModel. A common pattern here is to use the ->withVariables() method on the model that's passed to the presenter, by passing it an associative array of values.

Keep in mind that this example is still fairly simple and passing the values down from the controller might not be hard here, but it doesn't matter where/how deep your template is located. This would work exactly the same for a template that's nested 20 layers deep or reused across different pages.

How to install Shoot

Maybe the best thing about starting to use Shoot, is that it requires very minimal changes to your setup, because you can simply continue rendering templates as you're currently doing. If you don't define a model on your template, nothing changes, it's therefore very much opt-in on a per template basis.

Installation can be done through Composer:

$ composer require shoot/shoot

After that it's a matter of instantiating the Shoot Pipeline and attaching it to Twig:

$presenterMiddleware = new PresenterMiddleware($container);
$pipeline = new Pipeline([$presenterMiddleware]);
$installer = new Installer($pipeline);

// Add Shoot to Twig
$twig = $installer->install($twig);

As you can see Shoot has its own middleware, the most important one being the PresenterMiddleware, this is the one that enables the behavior I described above. There are other Shoot middlewares that ship with Shoot like a SuppressionMiddleware which suppresses RuntimeExceptions thrown from within an {% optional %} tag in your templates, a LoggingMiddleware that logs which templates are rendering including how long that takes and an InspectorMiddleware that logs information about template rendering to your browser console. All these are optional, although you'll want to add the PresenterMiddleware to enable the lazy data loading we're talking about.

Now you'll want to add Shoots PSR-15 middleware to your applications middleware stack. This grants Shoot access to your PSR-7 request that it can use in presenters to dynamically populate presentation models. In the example below I'm using the Idealo Middleware Stack:

$middlewareStack = new Stack(
    new EmptyResponse(),
    new ShootMiddleware($pipeline),
    $requestHandler
);

Now Shoot is completely set up and it's only a matter of actually executing your middleware stack and emitting the response.

Getting started

I've made a demo project available, which you can fork/clone/download.

GitHub logo ErikBooij / shootdemo

A tiny demo project for shoot

Shoot Demo

This is a tiny demo implementation of Shoot and serves a reference implementation to play around with for my blog post on using Shoot for lazily loading template data.

Install

Simply clone/download, run composer install and point your webserver at public/index.php (or composer run which will start PHP's built-in web server on port 80).

When you've got your local copy, it's a matter of running two commands (the second one needs elevated privileges because it binds to port 80 by default):

$ composer install
$ sudo composer run

Now you can go to http://localhost/?postId=1 to see Shoot in action.

Follow up

There is more you can do with Shoot, like passing down prepopulated models, or feeding data from your (parent) template back to a presenter. I'll cover this in a next write-up if there's any interest for it (feel free to express your interest by liking this post or following me).

If you have comments, questions or a request for some support, I hope I'll see you in the comments. Cheers!

Top comments (3)

Collapse
 
pierre profile image
Pierre-Henry Soria ✨

Thanks for the great article Erik.
Shoot Extension looks really interesting! And I like the way how it works.
I use Twig "almost" every day, so I will definitely give it a try for my next project, and hopefully share with you some thoughts/feedback about it :)

Collapse
 
erikbooij profile image
Erik Booij

That's good to hear! Curious about your findings 👌

Collapse
 
dekoningtim profile image
Tim de Koning

I have been waiting for a long time already for a new post. When are you writing one again? Thanks!