Extension points in Symfony

During the development of Cantiga, I ran into a problem of creating extension points, places where additional bundles could extend the core functionality. In Java world, dependency injection containers such as Guice make this task extremely trivial - you just declare an interface, register as many implementations as you want, and put a loop over them somewhere in your code. It’s possible, because they scan the injection points of the classes and construct the object graph automatically. Symfony Dependency Injection Container works in a slightly different way, and requires explicit orchestration in the configuration file. This is the tricky part, as we must teach it, what extension points are and how they work, in order to have them.

What is an extension point?

Extension points are one of the main concepts of designing modular application. For the purpose of this post, I provide the following definition:

Extension point is a place in the module (represented by an interface) that allows other modules extending the core functionality by hooking into it with custom implementations of that interface.

The most trivial implementation of an extension point is an interface:

interface FooInterface
{
    public function doSomething($data);
}

A method for registering custom implementations:

public function registerFoo(FooInterface $impl)
{
   $this->fooImplementations[] = $impl;
}

And a loop over them put somewhere in our code, that performs the actual job:

$data = iDidSomething();
foreach ($this->fooImplementations as $foo) {
   $foo->doSomething($data);
}

What we want to do is to use Symfony Dependency Injection mechanism to register new implementations. We will use the tag functionality and write some custom code for discovering our implementations. The provided solution will be based on the one that was implemented by me for Cantiga.

Implementation

Let’s begin with designing the intended way of configuring the extension points:

    my_service:
        class:     Foo\MyBundle\MyService
        tags:
            - { name: app.extension, point: core.xyz, description: "Some useful stuff" }

This piece of configuration shall create a service called my_service with implementation in Foo\MyBundle\MyService class. It will be registered in the extension point core.xyz, using app.extension. We would also like to provide a human-readable description, so that we could build configuration forms, where the user can select one of the implementations. The service can be registered in multiple extension points (if it implements all the necessary interfaces), and multiple services can be registered in the same extension point.

We need a dedicated service for managing all the registered implementations. We are going to write it now, and the service will have the following interface:

interface ExtensionPointsInterface
{
   public function describeImplementations(string $extPointName,
      ExtensionPointFilter $filter);
   public function hasImplementation(string $extPointName,
      ExtensionPointFilter $filter);
   public function getImplementation(string $extPointName,
      ExtensionPointFilter $filter);
   public function findImplementations(string $extPointName,
      ExtensionPointFilter $filter);
}

All the methods use the ExtensionPointFilter, a class that can be used to filter out certain implementations using some criteria. We’ll get back to it later, and meanwhile let’s see what our service is going to offer:

  • hasImplementation() - checks if there is any implementation registered for the given extension point,
  • getImplementation() - fetches the first implementation for the given extension point, that matches the filter criteria,
  • findImplementations() - fetches an array with all the implementations for the given extension point, that match the filter criteria,
  • describeImplementations() - fetches the meta-information about the implementations for the given extension points (i.e. service name, human-readable name).

The actual service must have a reference to Symfony container, and a map of extension points to implementations:

class ExtensionPoints implements ExtensionPointsInterface
{
   private $container;
   private $extensionPoints;
   
   public function __construct(Container $container)
   {
      $this->container = $container;
   }
   
   public function register(Implementation $impl)
   {
      if (!isset($this->extensionPoints[$impl->getExtensionPoint()])) {
         $this->extensionPoints[$impl->getExtensionPoint()] = [];
      }
      $this->extensionPoints[$impl->getExtensionPoint()][] = $impl;
   }
   
   public function registerFromArgs(string $extensionPoint, string $service, string $name = '')
   {
      $this->register(new Implementation($extensionPoint, $service, $name));
   }

Implementation is a simple container for three values: extension point name, service name, and human-readable name. If you want to store any extra information about the implementations, just add additional properties to it. The first two methods will be used by the extension point tag compiler pass. Note that we do not link the services by references, but by service names. This is necessary, as we construct the map during the configuration parsing, and Symfony DI has not created any service object at that point, yet. The service names must be resolved at the time of accessing the extension point. Another benefit of such approach is that the services are lazily initialized only if the extension point is actually being executed.

Let’s proceed to the actual extension point management:

   public function describeImplementations(string $extPointName, ExtensionPointFilter $filter)
   {
      if (!isset($this->extensionPoints[$extPointName])) {
         return array();
      }
      $results = array();
      foreach ($this->extensionPoints[$extPointName] as $impl) {
         if ($filter->matches($impl)) {
            $results[$impl->getName()] = $impl->getService();
         }
      }
      return $results;
   }
   public function findImplementations(string $extPointName, ExtensionPointFilter $filter)
   {
      if (!isset($this->extensionPoints[$extPointName])) {
         return false;
      }
      $results = array();
      foreach ($this->extensionPoints[$extPointName] as $impl) {
         if ($filter->matches($impl)) {
            $results[] = $this->container->get($impl->getService());
         }
      }
      return $results;
   }
   public function getImplementation(string $extPointName, ExtensionPointFilter $filter)
   {
      if (!isset($this->extensionPoints[$extPointName])) {
         return false;
      }
      foreach ($this->extensionPoints[$extPointName] as $impl) {
         if ($filter->matches($impl)) {
            return $this->container->get($impl->getService());
         }
      }
      throw new RuntimeException('No implementation for an extension point \''.$extPointName.'\'');
   }
   public function hasImplementation(string $extPointName, ExtensionPointFilter $filter)
   {
      if (!isset($this->extensionPoints[$extPointName])) {
         return false;
      }
      foreach ($this->extensionPoints[$extPointName] as $impl) {
         if ($filter->matches($impl)) {
            return true;
         }
      }
      return false;
   }
}

This part is rather straightforward. Every time we ask for an extension point, we run a loop over all the implementations, and apply the filter on it by calling $filter->matches($impl). If the filter accepts the implementation, we do something it, for example put into the collection that will be returned to the caller. The most basic template of an extension point filter is presented below. It can, and it should be extended. For example, Cantiga has a module functionality, which can be activated separately for each project in the administration panel. I assigned the services into modules, and the filter can select only those implementations that are delivered by the active modules. Other use cases are also possible.

class ExtensionPointFilter
{
	private $services = array();
	private $allServices = true;

	public function withModules(array $modules)
	{
		$newInstance = new ExtensionPointFilter();
		$newInstance->services = $this->services;
		$newInstance->allServices = $this->allServices;
		return $newInstance;
	}

	public function withServices(array $services)
	{
		$newInstance = new ExtensionPointFilter();
		$newInstance->services = array_merge($this->services, $services);
		$newInstance->allServices = false;
		return $newInstance;
	}
	
	public function matches(Implementation $implementation)
	{
		if (!$this->allServices) {
			if (!in_array($implementation->getService(), $this->services)) {
				return false;
			}
		}
		return true;
	}
}

Note that objects of this class are immmutable. Every time we add something to the filter, we create a new one. Immutability is excellent for concurrent programming, but the language such as PHP can also benefit from it. Note that here it allows you creating a base filter template with some preconfigured settings, and create multiple more specialized filters, or use the same filter in multiple places with a guarantee that previous invocations do not affect the current state.

The last thing to do is registering our extension point manager in Symfony as a service:

services:
    cantiga.extensions:
        class:     Foo\MyBundle\ExtensionPoints
        arguments: ["@service_container"]

Integration with Symfony

Our extension point service is ready. The next step is integrating it with the Symfony service configuration, so that the framework would know, what to do after encountering app.extension tag. Unfortunately, the documentation for extending the dependency injection container is quite confusing, at least for me. I spent some time trying and failing, before I finally realized how it actually works.

Symfony DI container can be itself extended by implementing our own configuration processors and compiler passes. Configuration processors are responsible for parsing the application configuration in app/config/config.yml file, which we won’t cover here. Compiler passes are responsible for processing the custom additions to the structure of services.yml file, and orchestrating Symfony how to compile them into PHP code. This is the key point to understand, how it works. Once we encounter our tag, we do not call the extension point service directly, but specify instructions for Symfony, how to do it for us. The framework might process it further into an executable PHP code to improve performance.

Custom compiler passes provided by our bundles are discovered by Symfony automatically, if we follow the naming conventions and put them into the correct namespace: MyBundle\DependencyInjection. Our class must be named SomethingPass:

class ExtensionPointPass implements CompilerPassInterface
{
   public function process(ContainerBuilder $container)
   {
      if (!$container->has('app.extensions')) {
         return;
      }
      $definition = $container->findDefinition('app.extensions'); // 1
      $taggedServices = $container->findTaggedServiceIds('app.extension'); // 2
      foreach ($taggedServices as $id => $tags) {
         foreach ($tags as $attr) {
            $args = [$attr['point']];
            $args[] = $id;
            if (isset($attr['description'])) {
               $args[] = $attr['description'];
            } else {
               $args[] = $id;
            }
            $definition->addMethodCall('registerFromArgs', $args); // 3
         }
      }
   }
}

How it works:

  1. tell Symfony that we’ll be operating on app.extensions service,
  2. find our services tagged with our tag (support for tags is built-in),
  3. translate the service definition into a call to ExtensionPoints::registerFromArgs() method,

And… that’s all.

Test it

Now we can check if our extension point service actually works. Let’s create one, called core.foo, where we require to implement FooInterface:

interface FooInterface
{
    public function doSomething($data);
}

We need at least one implementation, registered in services.yml:

class FooImpl
{
    public function doSomething($data)
    {
        echo 'It really works!';
    }
}
services:
    foo_service:
        class:     Foo\MyBundle\FooImpl
        tags:
            - { name: app.extension, point: core.foo, description: "My first extension point implementation" }

Finally, a piece of code that runs all the implementations - you can put it i.e. into a controller:

$extensionPoints = $this->get('app.extensions');
$implementations = $extensionPoints->findImplementations('core.foo', new ExtensionPointFilter());

foreach ($implementations as $impl) {
   $implementations->doSomething('hello');
}

Summary

The proposed implementation of extension points works very well for Cantiga. It makes use of the existing Symfony DI functionality, and the system of filters gives us additional capabilities of selecting only those implementations that match certain criteria. If you want to see a working solution, take a look at Cantiga: