Dynamically Registering Custom Twig Functions and Filters
Wednesday, 18 December 2024
Table of contents
I have used the Twig templating engine in a number of large projects - particularly CMS-based platforms. Whilst I favour Laravel's built-in Blade engine, Twig is ideal for user-generated templates as it is fast and secure, only allowing execution of pre-defined functions.
It is easy to extend the base set of functions and filters to match the capabilities of your application. You can do this by registering custom extensions to the Twig environment before rendering your templates:
$twig->addExtension(new YourCustomExtension);
The extension can then register any number of custom functions and filters. Typically you would combine the function / filter registration and execution methods in the same class (this example is for a Laravel application, so we can leverage some core functionality):
use Illuminate\Support\Str;
use Twig\TwigFunction;
use Twig\Extension\AbstractExtension;
use Twig\Extension\ExtensionInterface;
class YourCustomExtension extends AbstractExtension implements ExtensionInterface
{
public function getFunctions(): array
{
return [
// ...
new TwigFunction('get_current_path', [$this, 'getCurrentPath']),
// ...
];
}
public function getCurrentPath(): string
{
return request()->path();
}
// ...
public function getFilters(): array
{
return [
// ...
new TwigFilter('truncate', [$this, 'truncate']),
// ...
];
}
public function truncate(string $string, int $length = 100): string
{
return Str::of($string)->limit($length);
}
// ...
}
The example above will allow us to use the following custom function and filter in our Twig templates
{{ get_current_path() }}
{{ some_variable|truncate(50) }}
This is an effective approach, but over time as your requirements grow and you add more and more custom functionality to your templates, the custom extensions will also grow.
Dedicated Function and Filter Classes
I was drawn to the way in which Statamic automatically registers custom Modifiers (the equivalent of Twig filters, but for the Antlers templating language). You simply generate a modifier class - one class per modifier - and drop it into the the App\Modifiers
namespace and it is automatically available. I wanted to do something similar for Twig.
We'll take the same approach for filters and functions, so let's start with the get_current_path
function from the example.
namespace App\Twig\Functions;
class GetCurrentPath()
{
public function handle(): string
{
return request()->path();
}
}
Nothing special here - just a basic PHP class with a single handle
method (it could be anything - Statamic modifiers use an index
method).
In this instance the custom function classes will live in the App\Twig\Functions
namespace, alongside any number of other custom functions.
The formatting of the class name is important as we will rely on a common convention for generating the related Twig function signature. In this instance we will convert the class name into snake-case, so GetCurrentPath
will become get_current_path
.
The truncate
filter will follow the same pattern, but live in the App\Twig\Filters
namespace:
namespace App\Twig\Filters;
use Illuminate\Support\Str;
class Truncate()
{
public function handle(string $string, int $length = 100): string
{
return Str::of($string)->limit($length);
}
}
Automatic Registration
We want to automatically discover all functions and filters in our dedicated 'custom' namespaces. For this we will use the haydenpierce/class-finder package - a simple utility that will return an array of all classes in a given namespace.
composer require haydenpierce/class-finder
To keep things nicely separated we will keep the registration of custom functions inside the helper extension:
use Illuminate\Support\Str;
use HaydenPierce\ClassFinder\ClassFinder;
class YourCustomExtension extends AbstractExtension implements ExtensionInterface
{
public function getFunctions(): array
{
$functions = [];
// Prevents the ClassFinder looking inside vendor directories
ClassFinder::disablePSR4Vendors();
$classes = ClassFinder::getClassesInNamespace('App\Twig\Functions');
foreach ($classes as $class) {
$functions[] = new TwigFunction(
Str::of(class_basename($class))->snake(),
[new $class, 'handle']
);
}
return $functions;
}
}
The ClassFinder
utility will return an array of class names in a given namespace. We then loop through the classes, registering a TwigFunction
using the snake-cased version of the class name as the function signature. We target the handle
method on the class for executing the method.
The approach for registering filters is identical, except for the namespace and the type of Twig class that we are registering, so we can refactor this slightly.
use Illuminate\Support\Str;
use HaydenPierce\ClassFinder\ClassFinder;
class YourCustomExtension extends AbstractExtension implements ExtensionInterface
{
public function getFunctions(): array
{
return $this->registerHandlers(
type: TwigFunction::class,
namespace: 'App\Twig\Functions'
);
}
private function registerHandlers($type, $namespace): array
{
$handlers = [];
ClassFinder::disablePSR4Vendors();
$classes = ClassFinder::getClassesInNamespace($namespace);
foreach ($classes as $class) {
$handlers[] = new $type(
Str::of(class_basename($class))->snake(),
[new $class, 'handle']
);
}
return $handlers;
}
}
We can then register our filters by adding the following method to the custom extension:
public function getFilters(): array
{
return $this->registerHandlers(
type: TwigFilter::class,
namespace: 'App\Twig\Filters'
);
}
Finishing Up
This is a very basic implementation and there is a lot of room for improvement.
We are blindly assuming that there will always be a handle
method on the custom function / filter classes, so it's a good idea to add some checks before registering the class. Since the handle
method could potentially accepting any number of arguments we can't use an interface - however a simple method_exists
check will probably do the trick.
One thing I like about this approach is that is makes each custom function and filter easily testable, so adding some unit tests would be a great addition.