April 10, 2023 —John Koster
In this article, we will work through creating a custom Statamic Tag. Our custom tag will allow us to dynamically change the layout used by an Antlers template, similar to Blade's @extends
directive. The results of this article are available as a Statamic addon through the Marketplace:
However, if you are curious on the through process behind the implementation of the package, this article is for you!
Statamic provides a helpful command line utility to help scaffold custom addons and extensions. The command we use to do this is the make:addon
command, which accepts the vendor and package name. For me, I supplied the following input to the command:
1php please make:addon stillat/antlers-layouts
After the command has executed, we should see some output similar to the following in our terminal application:
1Creating addon...2[✓] Addon boilerplate created successfully.3 4Installing your addon with Composer. This may take a moment...5Repository added to your application's composer.json successfully.6[✓] Addon installed successfully.7 8🎉 Your addon package is ready: addons/stillat/antlers-layouts9Learn how to build addons in our docs: https://statamic.dev/extending/addons
If we navigate to the new folder created for us, we will see a small number of files and folders that were made for us:
1├── .gitignore2├── README.md3├── composer.json4└── src5 ├── ServiceProvider.php
The .gitignore
file contains a sensible default list of file and directory patterns that will be ignored by git.
The composer.json
file contains the minimum information Composer requires to autoload our custom package, and some extra information to help Statamic and Laravel understand our package. We will be making changes to this file later.
The more exciting file created by the make:addon
is the src/ServiceProvider.php
file. This file contains our addon's ServiceProvider
class, which will serve as the entry point for the package.
Statamic's service provider concept is very similar to Laravel's, providing a centralized location to configure our addon, such as registering custom Tags, event listeners, classes, etc. We will use some Statamic-specific features to register our custom Tag implementation later.
The first change we will make to our addon's composer.json
file is to require Statamic as a dependency:
1{ 2 "name": "stillat/antlers-layouts", 3 "autoload": { 4 "psr-4": { 5 "Stillat\\AntlersLayouts\\": "src" 6 } 7 }, 8 "require": { 9 "php": "^7.3|^8.0", 10 "statamic/cms": "^3.4" 11 }, 12 "extra": {13 "statamic": {14 "name": "Antlers Layouts",15 "description": "Antlers Layouts addon"16 },17 "laravel": {18 "providers": [19 "Stillat\\AntlersLayouts\\ServiceProvider"20 ]21 }22 }23}
We've added a new require
section to let Composer know our package's dependencies. The php
entry states that our package supports at least PHP 7.3 or PHP 8.0. The statamic/cms
entry means that our package can run on any version of Statamic, at least version 3.4
, but less than version 4
.
When working with our addon inside an existing Statamic application, we usually get away with skipping this step as the Statamic libraries will already be available to us. However, listing these dependencies will be necessary if we wanted to start writing tests for our addon.
With our dependencies defined, we can now issue the following command from within our addon's root directory (not the root directory of the Statamic site itself):
1composer update
After some time, our addon's dependencies will be installed inside a newly created vendor
folder.
Now that we have most of our boilerplate out of the way and our addon's dependencies have been installed, we need to start thinking of how we will implement our custom layout Tag. Ideally, we should be able to write Antlers code similar to the following to change the layout file our template will use:
1{{ layout:layouts/new-layout }}
If we are successful, Statamic will use the file located at resources/views/layouts/new-layout.antlers.html
as the layout for our template instead of the default resources/views/layout.antlers.html
.
How would we even begin implementing this? To get started, we can at least scaffold our Layout tag by creating a new file at addons/stillat/antlers-layouts/src/Tags/Layout.php
with the following contents:
1<?php 2 3namespace Stillat\AntlersLayouts\Tags; 4 5use Statamic\Tags\Tags; 6 7class Layout extends Tags 8{ 9 10}
Now that we have our custom Tag scaffolded, we must inform Statamic to automatically load it. We can do this by updating our addon's service provider class and adding our Layout Tag to the $tags
array:
1<?php 2 3namespace Stillat\AntlersLayouts; 4 5use Statamic\Providers\AddonServiceProvider; 6use Stillat\AntlersLayouts\Tags\Layout; 7 8class ServiceProvider extends AddonServiceProvider 9{10 protected $tags = [ 11 Layout::class, 12 ]; 13 14 public function bootAddon()15 {16 }17}
Perfect, we now have a custom Layout Tag scaffolded and have updated our addon's service provider to inform Statamic about it. However, we still need to figure out how to implement the dynamic layout functionality.
Before we can implement our custom layout behavior, we must determine how Statamic does this internally.
Through source diving, we will find that Statamic utilizes instances of the Statamic\View\View
class when rendering Antlers views. Of particular interest is the render
method, which makes a call to the shouldUseLayout
method to determine if a layout should be used or not:
1<?php 2 3namespace Statamic\View; 4 5// ... 6 7class View 8{ 9 // ...10 11 public function render(): string12 {13 $cascade = $this->gatherData();14 15 if ($this->shouldUseLayout()) { 16 // ...17 18 $contents = view($this->templateViewName(), $cascade);19 20 // ...21 22 $contents = view(23 $this->layoutViewName(), 24 // ...25 );26 } else {27 $contents = view($this->templateViewName(), $cascade);28 }29 30 // ...31 32 return $renderedContents;33 }34 35 // ...36}
If this check passes, we can see that it will render our template and call the layoutViewName
to determine which layout template should be used. Digging into the implementation of this method, we see that it references an internal $layout
property:
1<?php 2 3namespace Statamic\View; 4 5// ... 6 7class View 8{ 9 // ...10 11 protected function layoutViewName()12 {13 $view = $this->layout; 14 15 if (view()->exists($subdirectoried = 'layouts.'.$view)) {16 return $subdirectoried;17 }18 19 return $view;20 }21 22 // ...23}
Now the question becomes, "How does this property get set?". Searching the file for $this->layout =
, we can see that this variable can be set by calling the layout
method with arguments:
1<?php 2 3namespace Statamic\View; 4 5// ... 6 7class View 8{ 9 // ...10 11 public function layout($layout = null) 12 {13 if (func_num_args() === 0) {14 return $this->layout;15 }16 17 $this->layout = $layout; 18 19 return $this;20 }21 22 // ...23}
Perfect. We now know how we can influence the layout used by our Antler's template, but we have yet to figure out a way to do this from our custom Tag.
We have determined we can probably change the template's layout by calling the layout
method on the Statamic\View\View
instance. But how do we gain access to this View
instance in order to actually do this?
Through more source diving, we can see that there are no clear events or life-cycle hooks provided by Statamic to gain access to this class instance before the view has actually been rendered; at the point the view is rendered, it is already too late to change the layout.
Knowing that there are no clear events that will give us access to the current View
instance that will be used to generate our response, our next question is how Statamic creates this class instance? Are there some life-cycle hooks or events there?
After much more source diving, we can see that our View
instance is created within the Statamic\Http\Responses\DataResponse
class during typical front-end requests. Specifically, it is created inside the protected view
method:
1<?php 2 3namespace Statamic\Http\Responses; 4 5// ... 6 7class DataResponse implements Responsable 8{ 9 // ..10 11 protected function view()12 {13 return app(View::class) 14 ->template($this->data->template())15 ->layout($this->data->layout())16 ->with($this->with)17 ->cascadeContent($this->data);18 }19 20 // ...21}
Analyzing this file shows that no events or hooks are available to let us access the created View
instance; overriding core classes is also out of the question since that leads to headaches/problems later. It might be tempting to give up at this point since there does not appear to be any supported way to do this. However, suppose we study the view
method carefully. In that case, we can see that our View
instance is created by Laravel's container by calling the app
function.
The fact that Laravel's container will be creating the View
instances for Statamic is interesting since Laravel's container does have life-cycle events we can tap into whenever a specific class is resolved by the container.
We can use Laravel's resolving
method to be notified whenever a specific class is created/resolved by the container. Inside our addon's service provider class, we can make the following changes:
1<?php 2 3namespace Stillat\AntlersLayouts; 4 5use Statamic\Providers\AddonServiceProvider; 6use Statamic\View\View; 7use Stillat\AntlersLayouts\Tags\Layout; 8 9class ServiceProvider extends AddonServiceProvider10{11 protected $tags = [12 Layout::class,13 ];14 15 public function bootAddon()16 {17 app()->resolving(View::class, function ($view) { 18 dd($view); 19 }); 20 }21}
Attempting to refresh our site, we can see that the dd
function has dumped out the current View
instance being used by Statamic. Fantastic! We now have a way to access the current View
instance and can start implementing our custom Layout Tag.
Let's add properties and methods to our Layout tag inside src/Tags/Layout.php
and work through what each part is doing.
1<?php 2 3namespace Stillat\AntlersLayouts\Tags; 4 5use Statamic\Tags\Tags; 6use Statamic\View\State\ResetsState; 7 8class Layout extends Tags implements ResetsState 9{10 public static $lastView = null;11 12 public function index()13 {14 $this->setLayout($this->params->get('layout'));15 }16 17 protected function setLayout($layout)18 {19 if (self::$lastView != null) {20 self::$lastView->layout($layout);21 }22 }23 24 public function wildcard($tag)25 {26 $this->setLayout($tag);27 }28 29 public static function resetStaticState()30 {31 self::$lastView = null;32 }33}
First, the public methods, index
and wildcard
, are how we will interact with our custom Tag within an Antlers template. If we were to write the following in a template, the index
method would be called:
1{{ layout layout="custom-layout-file" }}
However, if we have the following Antlers template, the wildcard
method will be called, and the $tag
argument will be set to my-layout
:
1{{ layout:my-layout }}
Each method ultimately calls the protected setLayout
method, which calls the layout
method on a static $lastView
variable. This variable will eventually contain a reference to our View
instance, but we assume it has it for now.
The next interesting part of our custom Tag implementation includes the ResetsState
interface and the resetStaticState
static method. The Antlers Runtime will call our resetStaticState
method whenever it has finished creating a response, allowing us to reset any static variables we may have used. This is important as it will enable us to clean things up and prevent the current state from leaking between page generations, such as when using the static site generator (SSG) addon.
Now that our Tag will call the layout
method on its static $lastView
instance, we need to set this up. We will do this by updating our addon's service provider again; instead of using the dd
function to dump the current view, we will instead set the static $lastView
property on our Layout tag class:
1<?php 2 3namespace Stillat\AntlersLayouts; 4 5use Statamic\Providers\AddonServiceProvider; 6use Statamic\View\View; 7use Stillat\AntlersLayouts\Tags\Layout; 8 9class ServiceProvider extends AddonServiceProvider10{11 protected $tags = [12 Layout::class,13 ];14 15 public function bootAddon()16 {17 app()->resolving(View::class, function ($view) {18 dd($view); 19 Layout::$lastView = $view; 20 });21 }22}
If we were to add the following to a template in our Statamic project:
1{{ layout:my-custom-layout }}
and view that page in the browser, we are likely to receive a View [my-custom-layout] not found.
error. This is great news, as we can now dynamically swap out the layout file used by our Antlers templates.
Earlier in this article, I briefly mentioned that Statamic will first render the template and inject the rendered template's contents into the layout via. a template_contents
variable. This behavior can sometimes make it non-obvious to pass variables or custom state to our layout template without resorting to complication section hacks.
To help with this, we will update our Layout Tag to add a new share
method that will allow us to inject new variables into any layout view:
1{{ layout:share custom-variable="value"2 :custom-title="title" /}}
To accomplish this, we can add a new share
method to our Layout implementation:
1<?php 2 3namespace Stillat\AntlersLayouts\Tags; 4 5use Statamic\Tags\Tags; 6use Statamic\View\State\ResetsState; 7 8class Layout extends Tags implements ResetsState 9{10 public static $lastView = null;11 12 public static $variables = []; 13 14 public function index()15 {16 $this->setLayout($this->params->get('layout'));17 }18 19 protected function setLayout($layout)20 {21 if (self::$lastView != null) {22 self::$lastView->layout($layout);23 }24 }25 26 public function share() 27 { 28 self::$variables = array_merge( 29 self::$variables, $this->params->all() 30 ); 31 } 32 33 public function wildcard($tag)34 {35 $this->setLayout($tag);36 }37 38 public static function resetStaticState()39 {40 self::$lastView = null;41 self::$variables = []; 42 }43}
Each time we use the layout:share
Tag inside our Antlers template, it will merge all of the Tag's parameters with our static $variables
array. Similar to how we reset the $lastView
variable inside our resetStaticState
method, we also clear all of our variables in-between page generations.
Our layout:share
Tag will now add all of our custom variables to our $variables
array, but we still need to find a way to inject these into our layouts. To do this, we can take advantage of another Laravel concept: view composers.
An interesting side effect of adding the share
method is that we can no longer use the {{ layout:share }}
Tag to load a layout named "share". While this is certainly an unlikely occurrence, this is why we also added support for the index
method so template authors can instead use {{ layout layout="share" }}
.
View composers allow us to execute a callback function each time a view with a specific filename or pattern is created. We can update our addon's service provider like so:
1<?php 2 3namespace Stillat\AntlersLayouts; 4 5use Statamic\Providers\AddonServiceProvider; 6use Statamic\View\View; 7use Stillat\AntlersLayouts\Tags\Layout; 8 9class ServiceProvider extends AddonServiceProvider10{11 protected $tags = [12 Layout::class,13 ];14 15 public function bootAddon()16 {17 app()->resolving(View::class, function ($view) {18 Layout::$lastView = $view;19 });20 21 view()->composer(['layout', 'layouts/*'], function ($view) { 22 $view->with(Layout::$variables); 23 Layout::$variables = []; 24 }); 25 }26}
By passing an array containing both layout
and layouts/*
to the view()->composer()
method, each time the normal Statamic layout file is created, or any template within the resources/views/layouts
directory is created, our callback function is will be executed. We then utilize the with
method in our callback to inject all our variables into the layout.
With these changes, we can now pass custom variables from anywhere in our site's template back up to our template.
Throughout this article, we have taken several deep dives into the inner workings of Statamic and leveraged a few of Laravel's more exciting features to implement our custom Layout Tag. Using our custom Layout tag, we can now dynamically change our template's layout and share data with our layouts.
If you found this article useful, consider sharing it with your friends, colleagues, etc. It really helps! 😊
∎
The following amazing people help support this site and my open source projects ♥️
If you're interesting in supporting my work and want to show up on this list, check out my GitHub Sponsors Profile.