symfonyserviceconfigurationsymfony5php-8

Symfony - configure class from `service.yaml` with static default value


I am trying to create a Class that can be call from anywhere in the code.
It accepts different parameters that can be configured from the constructor (or setters).

This Class will be shared between several projects, so I need to be able to easily configure it once and use the same configuration (or different/specific one) multiple times.

Here's my class:

namespace Allsoftware\SymfonyBundle\Utils;

class GdImageConverter
{
    public function __construct(
        ?int $width = null,
        ?int $height = null,
        int|array|null $dpi = null,
        int $quality = 100,
        string $resizeMode = 'contain',
    ) {
        $this->width  = $width ? \max(1, $width) : null;
        $this->height = $height ? \max(1, $height) : null;

        $this->dpi = $dpi ? \is_int($dpi) ? [\max(1, $dpi), \max(1, $dpi)] : $dpi : null;

        $this->quality = \max(-1, \min(100, $quality));

        $this->resizeMode = $resizeMode;
    }
}

Most of the time, the constructor parameters will be the same for ONE application.
So I thought of using a private static variable that corresponds to itself, but already configured.

So I added the $default variable:

namespace Allsoftware\SymfonyBundle\Utils;

class GdImageConverter
{
    private static GdImageConverter $default;

    public function __construct(
        ?int $width = null,
        ?int $height = null,
        int|array|null $dpi = null,
        int $quality = 100,
        string $resizeMode = 'contain',
    ) {
        // ...
    }

    public static function setDefault(self $default): void
    {
        self::$default = $default;
    }

    public static function getDefault(): self
    {
        return self::$default ?? self::$default = new self();
    }
}

Looks like a Singleton but not really.
To set it up once and use GdImageConverter::getDefault() to get it, I wrote these lines inside the service.yaml file:

services:
    default.gd_image_converter:
        class: Allsoftware\SymfonyBundle\Utils\GdImageConverter
        arguments:
            $width: 2000
            $height: 2000
            $dpi: 72
            $quality: 80
            $resizeMode: contain

    Allsoftware\SymfonyBundle\Utils\GdImageConverter:
        calls:
            -   setDefault: [ '@default.gd_image_converter' ]

ATE when calling GdImageConverter::getDefault(), it does not correspond to the default.gd_image_converter service.

$default = GdImageConverter::getDefault();
$imageConverter = new GdImageConverter(2000, 2000, 72, 80);
dump($default);
dump($imageConverter);
die();

dump of variables

And when debugging self::$default inside getDefault(), it's empty.

What am I doing wrong ?

Note: When I change the calls method setDefault to a non-existing method setDefaults, symfony tells me that the method is not defined.

Invalid service "Allsoftware\SymfonyBundle\Utils\GdImageConverter": method "setDefaults()" does not exist.

Thank you!


Solution

  • Decided to post a new and hopefully more coherent answer.

    The basic problem is that GdImageConverter::getDefault(); returns an instance for which all the arguments are null. And that is because the Symfony container only creates services when they are asked for (aka injected). setDefault is never called so new self() is used.

    There is a Symfony class called MimeTypes which employs a similar pattern but it does not try to customize the service so it does not matter.

    There is a second problem with the way the GdImageConverter service is configured. It will basically inject a 'null' version even though it does set the default instant correctly.

    To fix the second problem you need to call setDefault with the current service and just get rid of default.gd_image_converter unless you need it for something else:

    services:
       App\Service\GdImageConverter:
            class: App\Service\GdImageConverter
            public: true
            arguments:
                $width: 2000
                $height: 2000
                $dpi: 72
                $quality: 80
                $resizeMode: contain
            calls:
                -   setDefault: [ '@App\Service\GdImageConverter' ]
    

    As a side note, the static method setDefault will be called dynamically. This is a bit unusual but it is legal in PHP and Symfony does it for other classes.

    Next we need to ensure the service is always instantiated. This is a rare requirement and I don't think there is a default way to do so. But using Kernel::boot works:

    # src/Kernel.php
    class Kernel extends BaseKernel
    {
        use MicroKernelTrait;
    
        public function boot()
        {
            parent::boot();
    
            $this->container->get(GdImageConverter::class);
        }
    }
    

    This ensures that the default service is set for both commands and web applications. GdImageConverter::getDefault(); can now be called at anytime and will return the initialized service. Notice that the service had to be declared public for Container::get to work.

    You could stop here but always creating a service even though you probably don't usually need it is kind of annoying. It is possible to avoid doing that by injecting the container itself into your class.

    This definitely violates Symfony's recommended practices and if the reader feels they need to downvote the answer for even suggesting it then do what you need to do. However the Laravel framework uses this approach (called facades) on a routine basis and those apps somehow manage to work.

    use Psr\Container\ContainerInterface;
    
    class GdImageConverter
    {
        private static GdImageConverter $default;
    
        private static ContainerInterface $container; // Add this
    
        public static function setContainer(ContainerInterface $container)
        {
            self::$container = $container;
        }
        public static function getDefault(): self
        {
          //return self::$default ?? self::$default = new self();
          return self::$default ?? self::$default = self::$container->get(GdImageConverter::class);
        }
    }
    # Kernel.php
       public function boot()
        {
            parent::boot();
    
            GdImageConverter::setContainer($this->container);
        }
    

    And now we are back to lazy instantiation.

    And while I won't provide the details you could eliminate the need to inject the container as well as making the service public by injecting a GdImageConverterServiceLocater.