PHPnews.io

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

Written by Tomáš Votruba / Original link on Aug. 17, 2020

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($mount);
             }
         ];
     }

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

Pros & Cons


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


Happy coding!

tomasvotruba

« How Basecamp Became a 100% Remote Company - The Workshop: System Management with Ansible php[architect] Magazine December 2019 »