Don't Give Up Your PHP Code for Compiler Passes so Easily

Sometimes you need to achieve very simple operation - e.g. get all services of a certain type in a certain order or key name. When we start to use a PHP framework, we tend to underestimate our PHP skills and look for the framework way.

Who cares if we use 50 lines in 3 files PHP files and 1 YAML file instead of 1 factory in 20 lines. We're cool!

This mini-series started in Why Config Coding Sucks. There we learned to move weakly un-typed strings to strict-typed PHP code. It's not only about YAML or NEON files, but about any config-like syntax in general (XML, in...).

Today we move to PHP-only land, that suffers a similar problem.

What We Talk About?

So we talk about Compiler Passes in Symfony? Well, yes and no. Not only about them, but about any PHP code that moves around services in the DI container.

They have their useful use-cases, but people tend them to use as a bazooka to mouse. Just look at answers under this StackOverflow question.


Let's look at an example that is not far from the reality of your work with. But still it's only an example, it could be apples in a basket instead.

Make Price Calculation easy to Extend and Maintain without Changing it

Based on my experience with my clients, this is the biggest problem in e-commerce projects. The ideal wishes of company owners clash with limits programmers and architecture:

This not possible! - How can we do it as close as possible now?

Let's say the solution is fairly easy. Same as Voters are to Security, we introduce 1 service PriceCalculator that collects all the little one PriceModifierInterface.

How would such implementations look like in framework-way?

1. In Symfony

<?php

namespace App\DependencyInjection;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

final class PriceCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $containerBuilder)
    {
        $priceCalculator = $containerBuilder->get(PriceCalculator::class);

        foreach ($containerBuilder->findTaggedServiceIds('price_modifier') as $service => $tags) {
            $priceCalculator->addMethodCall('add', [new Reference($service)]);
        }
    }
}

Again, we need to create some legacy code that is hard to maintain:


2. In Nette

<?php

namespace App\DI;

use Nette\DI\CompilerExtension;

final class PriceExtension extends CompilerExtension
{
    public function beforeCompile()
    {
        $containerBuilder = $this->getContainerBuilder();
        $priceCalculator = $containerBuilder->get(PriceCalculator::class);

        $priceModifiers = $containerBuilder->findByType(PriceModifierInterface::class);
        foreach ($priceModifiers as $service) {
            $priceCalculator->addSetup('add', [$service]);
        }
    }
}

Also, we create legacy code that is hard to maintain:

I need to take a break, my brain is tired just by making up this complicated and non-sense code. I mean, I used to write this code in my every project for 5 years in Symfony and Nette projects, because it was "the best practice" and I didn't question it, but there was always something scratching in the back of my head.



Keep Simple Things Simple

Now imagine you've ended in a train crash, hit your head and forget all the frameworks you know. All you have left is actually the best you can achieve in any mastery - a mind of the beginner.

In our specific example:

<?php

final class PriceCalculatorFactory
{
    /**
     * @var PriceModifierInterface[]
     */
    private $priceModifiers = [];

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

    public function create(): PriceCalculator
    {
        $priceModifiersByPriority = [];
        foreach ($this->priceModifiers as $priceModifier) {
            $priority = $priceModifier->getPriority(); // this could be "getKey()" or any metadata
            $priceModifiersByPriority[$priority] = $priceModifier;
        }

        // sort them in any way
        ksort($priceModifiersByPriority);

        return new PriceCalculator($priceModifiersByPriority);
    }
}

In some framework we have still have to add 1 config vendor-lock ❌ :

services:
    App\Price\PriceCalculator:
        factory: ['@App\Price\PriceCalculatorFactory', 'create']

I use compiler pass for now, but if you know how to remove it, let me know.

How we get $priceModifiers is not that important now, it's an implementation detail.

Durable & Readable

The important thing is we got a code that:


Happy coding!




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