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;
}
}
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
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.
static
❌SomeFilter
, we have to use static only ❌addFilter()
method with magic and make it harder to read, maintain and refactor ❌Can we do better?
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.
LatteFactory
to add a new filter ❌LatteFactory
will have over 100 of lines with various filters ❌Can we do better?
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!
LatteFactory
✅FilterProvider
will have over 100 of lines with various filters ❌Can we do better?
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:
LatteEngine
to add a new filter, nor a new filter serviceWhat 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 . ' €';
}
];
}
}
provide()
will be full of weird callbacks and long methods ❌Can we do better?
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 . ' €';
- }
}
MoneyFormatResolver
in other places of application ✅My question is: can we do better...?
Update 1 month later with new option:
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;
}
}
provideFilters()
callables with private methods ✅__invoke()
method has no contract, so we can forget to implement it ❌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!