Last month I successfully switched the Symfony container for Laravel one in Easy Coding Standard.
The tiny container is a joy to work with - it consists of 2 files I can read and understand all its features. I wanted to put this package into pressure test, so I migrated the project I work on daily - Rector.
Disclaimer: I have more experience with fixing leaking pipes in my flat than with Laravel, so if there is a better way of doing anything in this post, please let me know. I want to learn and write meaningful content. Thank you!
I'll write about particular details in other posts, but today I'd like to focus on a feature that cost me the most energy and time to figure out. In ECS and Rector, there is a skip feature.
Let's say you register whole NAMING
set to help you with variable/property namings but don't like the RenamePropertyToMatchTypeRector
one.
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\SetList;
use Rector\Naming\Rector\Class_\RenamePropertyToMatchTypeRector;
return function (RectorConfig $rectorConfig): void {
$rectorConfig->sets([
SetList::NAMING,
]);
$rectorConfig->skip([
RenamePropertyToMatchTypeRector::class,
]);
};
The set already registers many services (rules), and we want to use all of them but one. That means we have to remove the service from the container - that way, it doesn't run in the file processor and doesn't change the code.
The RectorConfig
class simply extends the Illuminate\Container\Container
class, so we have access to Laravel container logic.
Let's try the obvious one:
$rectorConfig->forgetInstance(RenamePropertyToMatchTypeRector::class);
Now, the container should forget about the instance. It kind of does - if we try to fetch the service from container, it will be created from fresh start (I think), but... everything else remains.
I've also found a more powerful method offsetUnset()
that removed service from bindings and resolved as well, but still with no effect.
Could you guess the possible issues? It took me 2-3 hours to figure out because, in previous containers like Symfony and Nette, this removed services from the whole container. I thought I was misusing the Laravel container, so I paid attention exclusively to my code instead of debugging Laravel internals.
The single service was removed, but the file is still changed.
I'll give you a clue. The service that changes PHP files is registered like this:
$rectorConfig->when(RectorNodeTraverser::class)
->needs('$rectors')
->giveTagged(RectorInterface::class);
How is the Rector rule registered?
$rectorConfig->singleton($rectorClass);
$rectorConfig->tag($rectorClass, RectorInterface::class);
The RectorNodeTraverser
still contains all the removed rules. But why?
If this bug happened to you, the answer just popped, or your frontal lobe.
What happens when we try to remove a tagged service?
We need to remove the RenamePropertyToMatchTypeRector
services from our service, so it will not change the code. What can we do?
add some special filter to all tagged iterators - overriding the framework's functions means we have to maintain any internal changes too
check the skipped classes manually in the RectorNodeTraverser
constructor - that would memory lock us to duplicate it every other services we pass the tagged services to
remove the service from tagged services too
I like the last one because that the behavior I expect when I remove any service from the container - all its tags, calls, and references should be gone too. Like it never existed.
So what does the tag()
method do?
$rectorConfig->tag($rectorClass, RectorInterface::class);
↓
$this->tags[RectorInterface::class][] = $rectorClass;
It adds a reference to a $tags
property. To remove the reference:
$rectorClass
equals our class we want to removeIt's like fixing a leak in water pipes - once we find the leaking weak spot, the fix is just an implementation detail.
So, the $tags
property is protected
, so we can extend the behavior in child class and override it or use reflection and separate the remove.
I prefer the latter one, as it's easier to test and less coupled to the framework:
use Illuminate\Container\Container;
function forgetInstance(Container $container, string $typeToForget): void
{
$tagsReflectionProperty = new ReflectionProperty($container, 'tags');
$tags = $tagsReflectionProperty->getValue($container);
// here we iterate all tags
foreach ($tags as $tagName => $taggedClasses) {
foreach ($taggedClasses as $key => $taggedClass) {
//Is it a match?
if (is_a($taggedClass, $typeToForget, true)) {
//let's remove it!
unset($tags[$tagName][$key]);
}
}
}
$tagsReflectionProperty->setValue($container, 'tags', $tags);
}
Not pretty, but it gets the job done.
If we had a direct accessor or public $tags
property, it would look much cleaner:
use Illuminate\Container\Container;
function forgetInstance(Container $container, string $typeToForget): void
{
foreach ($container->getTags() as $tagName => $taggedClasses) {
foreach ($taggedClasses as $key => $taggedClass) {
if (is_a($taggedClass, $typeToForget, true)) {
unset($tags[$tagName][$key]);
}
}
}
$container->updateTags($tags);
}
We have the script. Now the time comes to try it in the wild - will it work, or will it fail?
forgetInstance($rectorConfig, RenamePropertyToMatchTypeRector::class);
I dump the RectorNodeTraverser
constructor with the $rectors
collection... and there is one less rule! Yay!
That's it for today. I hope you've learned something, or at least I've used some weird obstructions that made you laugh. As always, let me know if you see a better way of doing things. Thanks!
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!