Skip to content

How to enhance final classes in open source with decorator pattern

Published: at 10:00 AM

In many open source repositories, especially those with just a few maintainers, you’ll often find classes declared as final. This approach allows maintainers to make changes without worrying too much about backward compatibility. However, it can also make the code a bit challenging to modify. The good news is that it’s not as difficult as it seems! Since these classes usually implement interfaces and utilize composition, we can tap into the advantages of the Decorator pattern.

Utilizing an open source project

In my search for an open source project with a significant user base, I came across EasyAdminBundle, which boasts over 18,000 users and 4,000 stars on GitHub. For this example, I’ll be focusing on the AdminUrlGenerator.php file. We will implement a dispatch event that gets triggered when the generate method is called.

Building a decorator class

Since AdminUrlGenerator.php is a final class, we cannot extend it or override the generate method. In this case, a suitable alternative is to use a decorator.

<?php

declare(strict_types = 1);

namespace App\Decorator;

use App\Event\UrlGeneratedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

#[AsDecorator(decorates: AdminUrlGenerator::class)]
class AdminUrlGeneratorDecorator implements AdminUrlGeneratorInterface
{
    public function __construct(
        #[AutowireDecorated]
        private AdminUrlGeneratorInterface $adminUrlGenerator,
        private EventDispatcherInterface $event,
    ) {
    }

    public function generateUrl(): string
    {
          $url = $this->adminUrlGenerator->generate();
          $this->event->dispatch(new UrlGeneratedEvent($url));

          return $url;
    }

    public function setRoute(string $routeName, array $routeParameters = []): self
    {
        $this->adminUrlGenerator->setRoute($routeName, $routeParameters);

        return $this;
    }

    public function get(string $paramName): mixed
    {
        return $this->adminUrlGenerator->get($paramName);
    }

    //....
}

Analyzing the AdminUrlGeneratorDecorator

#[AsDecorator(decorates: AdminUrlGenerator::class)]

//...

#[AutowireDecorated]

Since EasyAdminBundle is built on the Symfony Framework, it provides a way to decorate classes using Symfony’s built-in features. In the decorates parameter, you specify the class you want to decorate, which allows Symfony to inject AdminUrlGeneratorDecorator in place of the original AdminUrlGenerator.

By adding the #[AutowireDecorated] attribute above the $adminUrlGenerator property, Symfony automatically injects the original AdminUrlGenerator class into that parameter. This allows the decorator to use the functionality of the original class and extend it as needed.

<?php

final class AdminUrlGeneratorDecorator implements AdminContextProvider

Here we are creating the decorator and implementing the same interface used in AdminUrlGenerator class, it is important because this class is injected via __construct via AdminContextProvider interface, and as both class implements the same interface we can choose which class we want to in inject and in our case we gonna inject the new class created (AdminUrlGeneratorDecorator)

<?php

    public function __construct(
      #[AutowireDecorated]
      private AdminUrlGenerator $adminUrlGenerator,
      private EventDispatcherInterface $event,
    ) {
    }
<?php
    public function generateUrl(): string
    {
          $url = $this->adminUrlGenerator->generate();
          $this->event->dispatch(new UrlGeneratedEvent($url));

          return $url;
    }

It is the method that we want to modify, on the first line we are calling the original method to generate the url. the second line is dispatching our event and in the last one we are returning the url generated by original method.

<?php
    public function setRoute(string $routeName, array $routeParameters = []): self
    {
        $this->adminUrlGenerator->setRoute($routeName, $routeParameters);

        return $this;
    }

    public function get(string $paramName): mixed
    {
        return $this->adminUrlGenerator->get($paramName);
    }

    //....

Maybe you were asking yourself, “why do I need those methods if I just want to change generate method?”

It is needed because AdminContextProvider is forcing to implement those methods. and this case we must implement them and call the original implementation, like I did with setRoute and get methods

Just to simplify the example I did not add all methods, But you must add all of them.

Complete Implementation

//src/Decorator/AdminUrlGeneratorDecorator.php
<?php

declare(strict_types = 1);

namespace App\Decorator;

use App\Event\UrlGeneratedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

#[AsDecorator(decorates: AdminUrlGenerator::class)]
class AdminUrlGeneratorDecorator implements AdminUrlGeneratorInterface
{
// ...
//src/Event/UrlGeneratedEvent.php
<?php

declare(strict_types=1);

namespace App\Event;

class UrlGeneratedEvent
{
    public function __construct(private string $url)
    {
    }

    public function getUrl(): string
    {
        return $this->url;
    }
}
//src/Listener/UrlGeneratedListener.php
<?php

declare(strict_types=1);

namespace App\Listener;

use App\Event\UrlGeneratedEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
class UrlGeneratedListener
{
    public function __construct(private LoggerInterface $logger)
    {
    }

    public function __invoke(UrlGeneratedEvent $event)
    {
        $this->logger->info(
            'New url generated.',
            [
                'url' => $event->getUrl(),
                'eventClass' => get_class($event),
                'listenerClass' => get_class($this),
            ]
        );
    }
}

AdminContextProvider interface is used in some classes as MenuFactory.php, this class is called when we load some admin page that contain menu!

Then when a call /admin page, you can see there is a info log created by our Listener. Image with symfony profile page on log section


Next Post
A beginner's guide to setting up PHP on your computer