Playing with the Symfony tagged iterator

Published on 2024-08-27 • Modified on 2024-08-27

In this post, we see how we can use the Symfony tagged iterator to gather services of the same type. For sure, it is one of the most useful Symfony features. Let's go! 😎

» Published in "A week of Symfony 922" (26 August - 1 September 2024).

Prerequisite

I assume you have at least a basic knowledge of Symfony and what a service is.

Configuration

  • PHP 8.4
  • Symfony 6.4.20

Introduction

When developing the Symfony version of the is-php-dead.ovh project (have you tried it? 🤔), I had one requirement: I would avoiding arrays in the whole project.

Goal

We will see how to use the TaggedIterator attribute to gather services and some tricks.

Design questions

The is-php-dead.ovh website is a tribute to PHP but also an "easter egg" like game where you must find pages or do some actions to find those easter eggs. There is a page showing your achievements in the game; when nothing is found it is displayed like this:


The Is PHP dead result page

Each line represents an easter egg; the description is hidden if it is not found. An easter egg has a difficulty level and an order. Easy easter eggs are displayed first, then the "normal" ones and finally the "hard" ones. Because we can play once daily, displaying the eggs in the same order when returning to the website is essential.

So, how can we design this? Each easter egg can be a service implementing a generic easter egg class with common attributes:

<?php

declare(strict_types=1);

use App\Enum\EasterEggDifficulty;

/**
 * This is the base class for all EasterEgg instances.
 */
class EasterEgg
{
    public function __construct(
        public readonly EasterEggDifficulty $difficulty, // subjective difficulty to find the egg
        public readonly string $description,             // a brief description
        public bool $found = false,                      // if it was already found or not
        public ?\DateTimeImmutable $foundAt = null       // when it was found, null otherwise
    ) {
    }

    /**
     * Mark the egg as found.
     */
    public function markAsfound(): void
    {
        $this->found = true;
        $this->foundAt = new \DateTimeImmutable();
    }

We have several attributes, the difficulty level which uses a PHP enum:

<?php

declare(strict_types=1);

namespace App\Enum;

enum EasterEggDifficulty
{
    case easy;
    case medium;
    case hard;
}

It also includes a description, a flag of whether it was found, and the associated date. Each easter egg extends this class; let's see an example with a dummy one:

<?php

declare(strict_types=1);

use App\EasterEgg\EasterEgg;
use App\EasterEgg\EasterEggInterface;
use App\Enum\EasterEggDifficulty;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

/**
 * @see https://symfony.is-php-dead.ovh/xxx
 */
#[AsTaggedItem(priority: -1)]
final class EasterEggDummy extends EasterEgg implements EasterEggInterface
{
    public function __construct()
    {
        parent::__construct(
            EasterEggDifficulty::easy,
            'You haved clicked on the "xxx" hidden link of the "xxx" page.',
        );
    }
}

As planned, the egg extends the base easter egg class and implements the EasterEggInterface. Let's check this one:

<?php

declare(strict_types=1);

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

/**
 * Marker interface for all instanciated easter eggs.
 */
#[AutoconfigureTag]
interface EasterEggInterface
{
}

The EasterEggInterface is empty (this is a Marker Interface design pattern), but we can see it as an #[AutoconfigureTag] PHP attribute. Thanks to this attribute, we tell Symfony to associate the EasterEggInterface tag with all classes implementing this interface. So, the EasterEggDummy class gets it. Let's check this:

$ bin/console debug:container App\EasterEgg\Egg\EasterEggDummy
Information for Service "App\EasterEgg\Egg\EasterEggDummy"
==========================================================
---------------- ----------------------------------
Option           Value
---------------- ----------------------------------
Service ID       App\EasterEgg\Egg\EasterEggDummy
Class            App\EasterEgg\Egg\EasterEggDummy
Tags             App\EasterEgg\EasterEggInterface
Public           no
Synthetic        no
Lazy             no
Shared           yes
Abstract         no
Autowired        yes
Autoconfigured   yes
Usages           none
---------------- ----------------------------------

As predicted, the service has the App\EasterEgg\EasterEggInterface tag. Now, get back to the EasterEggDummy service.

Getting and ordering services

If we look at the EasterEggDummy service, we can notice that it has a #[AsTaggedItem(priority: -1)] PHP attribute. Because it is the first easter egg we want to display, we assign the -1 value (we will see why later). The second easter egg will get the -2 value and so on. -1 is greater than -2, so it has a "higher" priority.

We can use the TaggedIterator Symfony attribute to get all the easter eggs. They are stored in the $easterEggs property, ordered by the priority we set in the AsTaggedItem attribute we saw before.

<?php

declare(strict_types=1);

use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;

/**
 * This is just a service to gather EasterEggs services.
 */
final class EasterEggManager
{
    /**
     * @param iterable<EasterEgg>&\IteratorAggregate $easterEggs
     */
    public function __construct(
        #[TaggedIterator(\App\EasterEgg\EasterEggInterface::class, indexAttribute: 'index')]
        public readonly iterable $easterEggs,
    ) {
    }
}

We want to store this in session so save the state of each ester egg; was it found? When? The tagged iterator cannot be stored in session; we can convert it to a regular array thanks to the iterator_to_array() function.

<?php

declare(strict_types=1);

namespace App\EasterEgg;

final class EasterEggSessionManager
{
    /**
     * @param iterable<EasterEgg>&\IteratorAggregate $iterable
     */
    public static function fromTaggedIterator(iterable $iterable): self
    {
        /** @var array<EasterEgg> $easterEggs */
        $easterEggs = iterator_to_array($iterable, preserve_keys: true);

        return new self($easterEggs);
    }

    // ...
}

We want to preserve the keys as they represent the FQNC of each egg. Finally, the array looks like this:

array:8 [▼
  "App\EasterEgg\Egg\EasterEggDummy" => App\EasterEgg\Egg\EasterEggDummy {#154 ▼
    +difficulty: App\Enum\EasterEggDifficulty {#158 ▶}
    +description: "You have clicked on the "xxx" hidden link of the xxx page."
    +found: false
    +foundAt: null
  },
"App\EasterEgg\Egg\EasterEggOtherDummy" => App\EasterEgg\Egg\EasterEggOtherDummy {#155 ▼
...

And, of course, they are in the correct order thanks to the priority attribute we put on each egg. Now, getting an egg can be done with :

$egg = $this->easterEggs[EasterEggDummy::class];

I won't show the whole code, but you get it; now it is easy to retrieve a given egg, to change the found status; we can do this in a controller:

$egg = $this->easterEggManager->getEasterEggSessionManager()->getByClass(EasterEggDummy::class);
$this->easterEggManager->checkFound($egg);

The checkFound() method gets the egg in session and checks whether it is found. If not, it flags it as found, saves the new data in session and creates two flash messages to confirm to the users that they have found something. If the egg has already been found, then nothing is done.

Displaying the results

To display the results, you can add a small Twig extension that makes the session manager available in the Twig templates:

public function getFunctions(): array
{
    return [
        new TwigFunction('eesm', $this->getEasterEggSessionManager(...)),
    ];
}

Then, in the template, we can iterate over the eggs:

{% for egg in eesm().easterEggs %}
    <tr>
        <td>{{ loop.index }}</td>
        <td>{{ egg.found ? egg.description : '****************************************' }}</td>
        <td>{{ egg.difficulty.name }}</td>
        <td>{{ egg.found ? '✅' : '❌' }}</td>
        <td>{{ egg.found ? (egg.foundAt|date(constant('\DateTime::RFC850'))) : '' }}</td>
    </tr>
{% endfor %}

We can use the loop.index Twig variable because the eggs are in the correct order. But do we have to use this Twig special variable? Let's rely on the game data only. We saw before that eggs are sorted by priority, so we can use this priority to display this rank number. It is indeed the absolute value of the priority. In the EsterEgg class, let's add a function to access this value:

    /**
     * This is a little trick to get the order from the priority argument. This allow
     * avoiding to declare a property for this or using the Twig loop variable.
     */
    public function getOrder(): int
    {
        $refl = new \ReflectionClass($this::class);
        $attributes = $refl->getAttributes();
        /** @var int $priority */
        $priority = $attributes[0]->getArguments()['priority'];

        return abs($priority);
    }
}

We use reflection to extract the priority value from the class, then we return its absolute value. Now, we can use this new function instead of the Twig variable.

{{ egg.getOrder() }} {# or just "egg.order" thanks to Twig #}

Conclusion

The tagged iterator is one of the most useful Symfony features and allows gathering and iterating services with a lot of ease. I had a lot of fun building this website. Of course, there are multiple possible implementations, and this is one of the many possible ones that work. What is powerful here is that if we need to add an egg, we just have to create a new EsterEgg class and add the related detection code. Making a project without using arrays was also interesting. Don't hesitate to message me on Symfony Slack (COil) if you have other ideas for eggs or have found them all. They have yet to be found! 😁

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc  More on the web  More on the web  More on Stackoverflow

  Work with me!


Call to action

Did you like this post? You can help me back in several ways: (use the "reply" link on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! 😉

COil