In order to avoid instantiating services that might not be used, we use lazy services that get instantiated only when their proxy is interacted with. Symfony comes with a proxy generator, but it's kind of too much for what I need, and worst of all, it does not support final classes. Tired of writing handcrafted proxies, I finally rolled up my sleeves to write my own generator.

https://github.com/olvlvl/symfony-dependency-injection-proxy

Update-20181201: Since symfony/dependency-injection v4.2 it is possible to specify the interface that should be implemented by the proxy. The article was updated to demonstrate how to use that new feature.

What's up with Symfony's proxies?

Symfony uses ocramius/proxy-manager to generate its proxies. It's a great solution, and it can proxy everything and its dog, but it's not exactly tailored for dependency injection concerns. Also, it produces a large amount of code, like 10Kb for a class with one property and one method. Just check this comparison to get an idea. Besides, it's not working with final classes, which all of my classes implementing an interface are.

Handcrafted proxies

I could not bring myself to use Symfony's proxy generator. I had no need for most of the code that was generated, I did not want to remove final from my classes, and I had the sneaking suspicious it was impacting the performance of my application. So instead, I was writing my proxies by hand, and of course their tests too.

The following code is a proxy I wrote for a logger implementing LoggerInterface. You'll immediately notice there's a couple of sad things going on. The proxy has knowledge of the whole container and requires the service identifier. I could use a service locator that would only hold that one service, but frankly, at this point, why bother? It does the job, but none of this is great.

<?php

namespace HelloFresh\MenuService\Infrastructure\Logger;

use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LoggerTrait;

final class LoggerProxy implements LoggerInterface
{
    use LoggerTrait;

    /**
     * @var ContainerInterface
     */
    private $container;

    /**
     * @var string
     */
    private $id;

    /**
     * @var LoggerInterface
     */
    private $service;

    public function __construct(ContainerInterface $container, string $id)
    {
        $this->container = $container;
        $this->id = $id;
    }

    /**
     * @inheritdoc
     */
    public function log($level, $message, array $context = [])
    {
        $this->getService()->log($level, $message, $context);
    }

    private function getService(): LoggerInterface
    {
        return $this->service ?: $this->service = $this->container->get($this->id);
    }
}

The following code is the configuration required to wire the service and its proxy. You'll notice that the actual service needs to be public since the service will be instantiated from outside the container.

services:

  # …

  logger:
    class: Psr\Log\LoggerInterface
    factory: "logger_builder:build"
    arguments:
    - "%env(HF_SYSLOG_HOST)%"
    - "%env(HF_SYSLOG_PORT)%"
    public: true

  Psr\Log\LoggerInterface:
    class: HelloFresh\MenuService\Infrastructure\Logger\LoggerProxy
    arguments:
      $service: logger

The one thing I liked was the decorator-like vibe of my proxies. Because they are not extending the class, I can proxy final classes too, which most of my classes are. I wanted to keep that but get rid of the container, the service id, and keep private services private… And so began my quest for the tiniest proxies, and, to my despair, the quest for documentation (spoiler alert: there is none). I guess that's what Sundays are for.

Designing my own proxy generator

Compared to ocramius/proxy-manager, which can proxy you to the moon, my proxies are more like decorators, implementing the same interface as their target service. Also, because they leverage anonymous classes they are way simpler to generate and happily fit in the same method as their factory.

Consider the following class and interface:

<?php

interface SampleInterface
{
    public function getValue(): string;
}

final class Sample implements SampleInterface
{
    /**
     * @var string
     */
    private $value;

    public function __construct(string $value)
    {
        $this->value = $value;
    }

    /**
     * @inheritdoc
     */
    public function getValue(): string
    {
        return $this->value;
    }
}

A proxy for Sample would look something like this:

<?php

    // …

    protected function getSampleInterfaceService($lazyLoad = true)
    {
        if ($lazyLoad) {
            return $this->services['SampleInterface'] = new class(
                function () {
                    return $this->getSampleInterfaceService(false);
                }
            ) implements \SampleInterface
            {
                private $factory, $service;

                public function __construct(callable $factory)
                {
                    $this->factory = $factory;
                }

                public function getValue(): string
                {
                    return ($this->service ?: $this->service = ($this->factory)())->getValue();
                }
            };
        }

        return new \Sample('aValue');
    }

    // …

The dumped container is now ~2600 bytes, and I can keep final on my class. Compared to the ~10500 bytes of Symfony's, it's a nice reduction in size and complexity.

Building the container

The container is built the same way as with symfony/proxy-manager-bridge:

<?php

use olvlvl\SymfonyDependencyInjectionProxy\FactoryRenderer;
use olvlvl\SymfonyDependencyInjectionProxy\InterfaceResolver\BasicInterfaceResolver;
use olvlvl\SymfonyDependencyInjectionProxy\MethodRenderer;
use olvlvl\SymfonyDependencyInjectionProxy\ProxyDumper;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;

/* @var ContainerBuilder $builder */

$builder->compile();

$dumper = new PhpDumper($builder);
$dumper->setProxyDumper(new ProxyDumper());

What if my lazy service implements multiple interfaces?

The basic interface resolver will have a hard time figuring out which interface to implement if a service implements several. For instance, if a service was an instance of ArrayObject the following exception would be raised:

Don't know which interface to choose from for ArrayObject: IteratorAggregate, Traversable, ArrayAccess, Serializable, Countable.

Since symfony/dependency-injection v4.2 it's possible to specify which interface should be implemented by the proxy using the lazy keyword:

services:
  ArrayObject:
    lazy: ArrayAccess

For older versions, we can help by decorating the basic interface resolver with a map, and specify which interface to implement for which class. The following example demonstrates how we hint that proxies to ArrayObject services should implement ArrayAccess:

<?php

/* @var PhpDumper $dumper */

$dumper->setProxyDumper(new ProxyDumper(
    new MapInterfaceResolver(new BasicInterfaceResolver(), [
        ArrayObject::class => ArrayAccess::class,
    ])
));

Simpler service definitions

Now it's super simple to make my services lazy. Remember the definition for my logger?

services:

  # …

  logger:
    class: Psr\Log\LoggerInterface
    factory: "logger_builder:build"
    arguments:
    - "%env(HF_SYSLOG_HOST)%"
    - "%env(HF_SYSLOG_PORT)%"
    public: true

  Psr\Log\LoggerInterface:
    class: HelloFresh\MenuService\Infrastructure\Logger\LoggerProxy
    arguments:
      $service: logger

It now looks like this:

services:

  # …

  Psr\Log\LoggerInterface:
    factory: "logger_builder:build"
    arguments:
    - "%env(HF_SYSLOG_HOST)%"
    - "%env(HF_SYSLOG_PORT)%"
    lazy: true

Conclusion

I'm happy with my solution: it's very simple to use, it generates super tiny proxies, and it can proxy final classes. I was able to get rid of my handcrafted proxies without changing anything to my applications. I even switched the lazy mode on a couple of services I was too lazy (pun!) to write proxies for. With any luck, it could suit your application too. Let me know :)