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> <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 RuntimeException
s 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.
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)
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 :)
That's good to hear! Curious about your findings 👌
I have been waiting for a long time already for a new post. When are you writing one again? Thanks!