How to Get Rid of Magic, Static and Chaos from Latte Filters

This post was updated at September 2020 with fresh know-how.
What is new?

Added options 6 - invokable filter providers.


In the previous post, we looked at how to avoid array magic and duplicates of Latte in Presenter and Components.

Today we'll leverage those tips to make your code around Latte filters easy and smooth to work with.

Do you have your LatteFactory service ready? If not, create it first, because we'll build on it.


<?php

declare(strict_types=1);

namespace App\Latte;

use Latte\Engine;
use Latte\Runtime\FilterInfo;

final class LatteFactory
{
    public function create(): Engine
    {
        $engine = new Engine();
        $engine->setStrictTypes(true);

        return $engine;
    }
}

How to register a new Latte Filter?

This simple question can add easily add an anti-pattern to your code, that spreads like COVID and inspires developers to add more anti-patterns. It's easy to submit to static infinite loop, I did it too.

But let's look at practice... how to add a filter?

Let's say we want to format money. The filter code is not relevant here, so we go with the simplest version possible:

class SomeFilter
{
    public function money(int $amount): string
    {
        return $amount . ' €';
    }
}

We'll use it template like this:

You're total invoice amount:
<strong>{$amount|money}</strong>

Thank you

1. Register Static Magic Loader

This used to be the best practice in 2014. Just add a class and magically delegate called filter name:

 namespace App\Latte;

 use Latte\Engine;
 use Latte\Runtime\FilterInfo;

 final class LatteFactory
 {
     public function create(): Engine
     {
         $engine = new Engine();
         $engine->setStrictTypes(true);
+        $engine->addFilter(null, SomeFilter::class . '::loader');

         return $engine;
    }
 }

And add loader() method:

 class SomeFilter
 {
-    public function money(int $amount): string
+    public static function money(int $amount): string
     {
         return $amount . ' €';
     }

+    public static function loader($arg)
+    {
+        $arg = \func_get_args();
+        $func = \array_shift($arg);
+        if (\method_exists(self::class, $func)) {
+            return \call_user_func_array([self::class, $func], $arg);
+        }
+
+         return null;
     }
 }

This is my favorite magic part:

$engine->addFilter(null, SomeFilter::class . '::loader');

Do you have any what is happening there? I don't.


Pros & Cons


Can we do better?

2. Register Function manually with addFilter()

The addFilter() can be used in the way it's designed for:

 namespace App\Latte;

 use Latte\Engine;
 use Latte\Runtime\FilterInfo;

 final class LatteFactory
 {
     public function create(): Engine
     {
         $engine = new Engine();
         $engine->setStrictTypes(true);
+        $engine->addFilter('money', function (int $amount): string {
+             return $amount . ' €';
+        });

         return $engine;
    }
 }

Straight forward, transparent, and a few lines of code.

Pros & Cons


Can we do better?

3. Add Filter Provider Service?

The previous solution looks fine, if only we could get rid of coupling between framework and our code.

 namespace App\Latte;

 use Latte\Engine;
 use Latte\Runtime\FilterInfo;

 final class LatteFactory
 {
+    private FilterProvider $filterProvider;
+
+    public function __construct(FilterProvider $filterProvider)
+    {
+        $this->filterProvider = $filterProvider;
+    }

     public function create(): Engine
     {
         $engine = new Engine();
         $engine->setStrictTypes(true);

+        foreach ($this->filterProvider->provide() as $filterName => $filterCallback) {
+            $engine->addFilter($filterName, $filterCallback);
+        }

         return $engine;
    }
 }
<?php

final class FilterProvider
{
    /**
     * @return array<string, callable>
     */
    public function provide(): array
    {
        return [
            'money' => function (int $amount): string {
                return $amount . ' €';
            }
        ];
    }
}

The filter class is decoupled - no more hard-coded filters!

Pros & Cons


Can we do better?

4. Filter Provider Contract

The ultimate solution is almost perfect. We only need to get rid of the God class completely. How can we do that?

The goal is simple:


What if we use autowired arrays feature from Nette 3.0?


 namespace App\Latte;

+use App\Contract\FilterProviderInterface;
 use Latte\Engine;
 use Latte\Runtime\FilterInfo;

 final class LatteFactory
 {
+    private array $filterProvider;
+
+    /**
+     * @param FilterProviderInterface[] $filterProviders
+     */
+    public function __construct(array $filterProviders)
+    {
+        $this->filterProviders = $filterProviders;
+    }

     public function create(): Engine
     {
         $engine = new Engine();
         $engine->setStrictTypes(true);

+        foreach ($this->filterProviders as $filterProvider) {
+            foreach ($filterProvider->provide() as $filterName => $filterCallback) {
+                $engine->addFilter($filterName, $filterCallback);
+            }
+        }

         return $engine;
    }
 }
namespace App\Contract;

interface FilterProviderInterface
{
    /**
     * @return array<string, callable>
     */
    public function provide();
}
+use App\Contract\FilterProviderInterface;

-final class FilterProvider
+final class MoneyFilterProvider implements FilterProviderInterface
 {
     /**
      * @return array<string, callable>
      */
     public function provide(): array
     {
         return [
             'money' => function (int $amount): string {
                 return $amount . ' €';
             }
         ];
     }
 }

Pros & Cons


Can we do better?


5. From Callbacks to Private Methods

 use App\Contract\FilterProviderInterface;

 final class MoneyFilterProvider implements FilterProviderInterface
 {
     /**
      * @return array<string, callable>
      */
     public function provide(): array
     {
         return [
             'money' => function (int $amount): string {
-                return $amount . ' €';
+                return $this->money($mount);
             }
         ];
     }

+    private function money(int $amount): string
+    {
+        return $amount . ' €';
+    }
 }

This looks like a duplicated code, right?

But what if money filters grow, included timezones and logged in user country? Is MoneyFilterProvider the best place to handle all this logic?

 use App\Contract\FilterProviderInterface;

 final class MoneyFilterProvider implements FilterProviderInterface
 {
+    private MoneyFormatResolver $moneyFormatResolver;
+
+    public function __construct(MoneyFormatResolver $moneyFormatResolver)
+    {
+       $this->moneyFormatResolver = $moneyFormatResolver;
+    }

     /**
      * @return array<string, callable>
      */
     public function provide(): array
     {
         return [
             'money' => function (int $amount): string {
-                return $this->money($mount);
+                return $this->moneyFormatResolver->resolve($amount);
             }
         ];
     }

-    private function money(int $amount): string
-    {
-        return $amount . ' €';
-    }
 }

Pros & Cons


My question is: can we do better...?


Update 1 month later with new option:

6. Invokable Filter Providers

In fashion of single-action controller a tip from @FrantisekMasa and @dada_amater for similar approach in filters. It look weird, new... so I had to try it in practise to see for myself.

namespace App\Contract;

interface FilterProviderInterface
{
    public function getName(): string;
}

The filter itself - 1 filter = 1 class:

use App\Contract\FilterProviderInterface;

final class MoneyFilterProvider implements FilterProviderInterface
{
    private MoneyFormatResolver $moneyFormatResolver;

    public function __construct(MoneyFormatResolver $moneyFormatResolver)
    {
       $this->moneyFormatResolver = $moneyFormatResolver;
    }

    public function __invoke(int $amount): string
    {
        return $this->moneyFormatResolver->resolve($amount);
    }
}

The LatteFactory now acceps the whole filter as callable object:

namespace App\Latte;

use Latte\Engine;
use Latte\Runtime\FilterInfo;

final class LatteFactory
{
    /**
     * @var FilterProviders[]
     */
    private array $filterProviders = [];

    /**
     * @param FilterProvider[]
     */
    public function __construct(array $filterProviders)
    {
        $this->filterProviders = $filterProviders;
    }

    public function create(): Engine
    {
        $engine = new Engine();
        $engine->setStrictTypes(true);

        foreach ($this->filterProviders as $filterProvider) {
            $engine->addFilter($filterProvider->getName(), $filterProvider);
        }

        return $engine;
    }
}

Pros & Cons

We compensate this in LatteFactory itself:

public function create(): Engine
{
    $engine = new Engine();
    $engine->setStrictTypes(true);

    foreach ($this->filterProviders as $filterProvider) {
        if (! method_exists($filterProvider, '__invoke')) {
            $message = sprintf('Add "__invoke()" method to filter provider "%s"', get_class($filterProvider));
            throw new ShouldNotHappenException($message);
        }

        $engine->addFilter($filterProvider->getName(), $filterProvider);
    }

    return $engine;
}

That's it!


Happy coding!




Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!