Decoration with Symfony's Container
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe just implemented the decorator pattern, where we basically wrapped the original XpCalculator
in a warm hug with our OutputtingXpCalculator
. Then... we quietly slipped that into the system in place of the original... without anyone else - like XpEarnedObserver
- knowing or caring that we did that:
// ... lines 1 - 17 | |
class GameCommand extends Command | |
{ | |
// ... lines 20 - 26 | |
protected function execute(InputInterface $input, OutputInterface $output): int | |
{ | |
$xpCalculator = new XpCalculator(); | |
$xpCalculator = new OutputtingXpCalculator($xpCalculator); | |
$this->game->subscribe(new XpEarnedObserver($xpCalculator)); | |
// ... lines 32 - 47 | |
} | |
// ... lines 49 - 105 | |
} |
But to set up the decoration, I'm instantiating the objects manually, which is not very realistic in a Symfony app. What we really want is for XpEarnedObserver
to autowire XpCalculatorInterface
like normal, without us needing to do any of this manual instantiation. But we need the container to pass it our OutputtingXpCalculator
decorator service, not the original XpCalculator
. How can we accomplish that? How can we tell the container that whenever someone type-hints XpCalculatorInterface
, it should pass our decorator service?
To answer that, let's start by undoing our manual code: In both GameCommand
... and then Kernel
... put back the fancy code that attaches the observer to GameApplication
:
// ... lines 1 - 14 | |
class GameCommand extends Command | |
{ | |
// ... lines 17 - 23 | |
protected function execute(InputInterface $input, OutputInterface $output): int | |
{ | |
$io = new SymfonyStyle($input, $output); | |
$io->text('Welcome to the game where warriors fight against each other for honor and glory... and 🍕!'); | |
// ... lines 29 - 40 | |
} | |
// ... lines 42 - 98 | |
} |
// ... lines 1 - 11 | |
class Kernel extends BaseKernel implements CompilerPassInterface | |
{ | |
// ... lines 14 - 21 | |
public function process(ContainerBuilder $container) | |
{ | |
// ... lines 24 - 25 | |
foreach ($taggedObservers as $id => $tags) { | |
$definition->addMethodCall('subscribe', [new Reference($id)]); | |
} | |
} | |
} |
If we try the command now:
php ./bin/console app:game:play
It fails:
Cannot autowire service
XpEarnedObserver
: argument$xpCalculator
references interfaceXpCalculatorInterface
but no such service exists. You should maybe alias this interface to one of these existing services:OutputtingXpCalculator
orXpCalculator
.
Manually Wiring up the Service Decoration: Alias
That's a great error... and it makes sense. Inside of our observer, we're type-hinting the interface instead of a concrete class. And, unless we do a little more work, Symfony doesn't know which XpCalculatorInterface
service to pass us. How do we tell it? By creating a service alias.
In config/services.yaml
, say App\Service\XpCalculatorInterface
set to @App\Service\OutputtingXpCalculator
:
// ... lines 1 - 7 | |
services: | |
// ... lines 9 - 25 | |
App\Service\XpCalculatorInterface: '@App\Service\OutputtingXpCalculator' |
This creates a service whose id is App\Service\XpCalculatorInterface
... but it's really just a "pointer", or "alias" to the OutputtingXpCalculator
service. And remember, during autowiring, when Symfony sees an argument type-hinted with XpCalculatorInterface
, to figure out which service to pass, it simply looks in the container for a service whose id matches that, so App\Service\XpCalculatorInterface
. And now, it finds one!
So, let's try it again.
php ./bin/console app:game:play
And... it still doesn't work. We're on a roll!
Circular reference detected for service
OutputtingXpCalculator
, path:OutputtingXpCalculator
->OutputtingXpCalculator
Oh! Symfony is autowiring OutputtingXpCalculator
into XpEarnedObserver
... but it's also autowiring OutputtingXpCalculator
into itself:
// ... lines 1 - 7 | |
class OutputtingXpCalculator implements XpCalculatorInterface | |
{ | |
public function __construct( | |
private readonly XpCalculatorInterface $innerCalculator | |
) | |
{ | |
} | |
// ... lines 15 - 30 | |
} |
Whoops! We want OutputtingXpCalculator
to be used everywhere in the system that autowires XpCalculatorInterface
... except for itself.
To accomplish that, back in services.yaml
, we can manually configure the service. Down here, add App\Service\OutputtingXpCalculator
with arguments
, $innerCalculator
(that's the name of our argument) set to @App\Service\XpCalculator
:
// ... lines 1 - 7 | |
services: | |
// ... lines 9 - 27 | |
App\Service\OutputtingXpCalculator: | |
arguments: | |
$innerCalculator: '@App\Service\XpCalculator' |
This will override the argument for just this one case. And now...
php ./bin/console app:game:play
It work? I mean, of course it works! If we play a few rounds and fast forward... yes! There's the "you've leveled up" message! It did go through our decorator!
This way of wiring the decorator is not our final solution. But before we get there, I have an even bigger challenge: let's completely replace a core Symfony service with our own via decoration. That's next!