/slashes

Dynamically Registering Custom Twig Functions and Filters

Wednesday, 18 December 2024

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.

Email a comment