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:
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
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 )
- Report any error/typo.
- Report something that could be improved.
- Like and repost!
- Follow me on Bluesky 🦋
- Subscribe to the RSS feed.
- Click on the More on Stackoverflow buttons to make me win "Announcer" badges 🏅.
Thank you for reading! And see you soon on Strangebuzz! 😉
