Leonardo Losoviz Freelance developer and writer, with an ongoing quest to integrate innovative paradigms into existing PHP frameworks, and unify all of them into a single mental model.

Building extensible PHP apps with Symfony DI

7 min read 2235

Building Extensible PHP Apps with Symfony DI

When building complex PHP applications, we can rely on dependency injection and service containers to manage the instantiation of the objects, or “services,” in the application.

There are several dependency injection libraries that satisfy PSR-11, the PHP standard recommendation that describes the contract for a “container interface”:

With 3.4K stars on GitHub, Symfony’s DependencyInjection is a step above similar libraries. It’s extremely powerful, yet simple to use. Since the logic of how all services must be initialized can be generated and dumped as a PHP file, it’s fast to run in production. It can be configured to service both PHP and YAML. And it’s easily comprehended because it’s backed by extensive documentation.

Using service containers is already helpful for managing complex applications. Equally importantly, service containers diminish the need for external developers to produce code for our apps.

For instance, our PHP application could be extensible via modules, and third-party developers could code their own extensions. By using a service container, we make it easier for them to inject their services into our application, even if they don’t have a deep understanding of how our application works. That’s because we can program rules to define how the service container initializes the services and automate this process.

This automation translates into work that developers don’t have to do anymore. As a consequence, they won’t need to understand the internal, nitty-gritty details of how the service is initialized; that’s taken care of by the service container.

Though developers will still need to understand the concepts behind dependency injection and container services, by using the DependencyInjection library, we can simply direct them to Symfony’s documentation on the topic. Reducing the amount of documentation we need to maintain makes us happier and frees up time and resources to work on our code.

In this article, we will look at some examples of how to use DependencyInjection library to make a PHP application more extensible.

Working with compiler passes

Compiler passes are the library’s mechanism to modify how the services in the container are initialized and invoked just before the service container is compiled.

A compiler pass object must implement CompilerPassInterface:

We made a custom demo for .
No really. Click here to check it out.

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class OurCustomPass implements CompilerPassInterface
{
  public function process(ContainerBuilder $container)
  {
    // ... do something during the compilation
  }
}

To register it in our app, we do the following:

use Symfony\Component\DependencyInjection\ContainerBuilder;

$containerBuilder = new ContainerBuilder();
$containerBuilder->addCompilerPass(new OurCustomPass());

We can inject as many compiler passes as we need:

// Inject all the compiler passes
foreach ($compilerPasses as $compilerPass) {
  $containerBuilder->addCompilerPass($compilerPass);
}
// Compile the container
$containerBuilder->compile();

Automatically initializing services

Through a compiler pass, we can automatically initialize services of a certain kind — for example, any class that extends from a certain class, implements certain interfaces, has a certain service tag assigned to its definition, or some other custom behavior.

Let’s look at an example. We will make our PHP app automatically initialize any object that implements AutomaticallyInstantiatedServiceInterface by invoking its initialize method:

interface AutomaticallyInstantiatedServiceInterface
{
  public function initialize(): void;
}

We can then create a compiler pass that will iterate the list of all services defined in the container and identify those services implementing AutomaticallyInstantiatedServiceInterface:

class AutomaticallyInstantiateServiceCustomPass implements CompilerPassInterface
{
  public function process(ContainerBuilder $container)
  {
    $definitions = $container->getDefinitions();
    foreach ($definitions as $definitionID => $definition) {
      $definitionClass = $definition->getClass();
      if ($definitionClass === null || !is_a($definitionClass, AutomaticallyInstantiatedServiceInterface::class, true)) {
        continue;
      }

      // $definition is a AutomaticallyInstantiatedServiceInterface
      // Do something with it
      // ...
    }
  }
}

Next, we’ll create a service called ServiceInstantiatorInterface, which will be in charge of initializing the identified services. With the addService method, it will collect all services to initialize, and its method initializeServices will be eventually invoked by the PHP application:

interface ServiceInstantiatorInterface
{
  public function addService(AutomaticallyInstantiatedServiceInterface $service): void;
  public function initializeServices(): void;
}

The implementation for this service is available on GitHub:

class ServiceInstantiator implements ServiceInstantiatorInterface
{
  /**
   * @var AutomaticallyInstantiatedServiceInterface[]
   */
  protected array $services = [];

  public function addService(AutomaticallyInstantiatedServiceInterface $service): void
  {
    $this->services[] = $service;
  }

  public function initializeServices(): void
  {
    foreach ($this->services as $service) {
      $service->initialize();
    }
  }
}

We can now complete the code for the compiler pass above by injecting all identified services into the ServiceInstantiatorInterface service:

class AutomaticallyInstantiateServiceCustomPass implements CompilerPassInterface
{
  public function process(ContainerBuilder $container)
  {
    $serviceInstantiatorDefinition = $container->getDefinition(ServiceInstantiatorInterface::class);
    $definitions = $container->getDefinitions();
    foreach ($definitions as $definitionID => $definition) {
      $definitionClass = $definition->getClass();
      if ($definitionClass === null) {
        continue;
      }
      if (!is_a($definitionClass, AutomaticallyInstantiatedServiceInterface::class, true)) {
        continue;
      }

      // $definition is a AutomaticallyInstantiatedServiceInterface
      // Do something with it
      $serviceInstantiatorDefinition->addMethodCall(
        'addService',
        [new Reference($definitionID)]
      );
    }
  }
}

Being a service itself, the definition for ServiceInstantiatorInterface is also found on the service container. That’s why, to obtain a reference to this service, we must do:

$serviceInstantiatorDefinition = $container->getDefinition(ServiceInstantiatorInterface::class);

We’re not working with the instantiated object/services because we don’t have them yet. Instead, we’re dealing with the definitions for the services on the container. That’s also why, to inject a service into another service, we can’t do this:

$serviceInstantiator->addService(new $definitionClass());

But must do this instead:

$serviceInstantiatorDefinition->addMethodCall(
  'addService',
  [new Reference($definitionID)]
);

The PHP application must trigger the initialization of the services when it boots:

$serviceInstantiator->initializeServices();

Finally, we make those services that need to be automatically initialized implement AutomaticallyInstantiatedServiceInterface.

In this example, our app uses SchemaConfiguratorExecuter services. The initialization logic is already satisfied by their ancestor class, AbstractSchemaConfiguratorExecuter, like this:

abstract class AbstractSchemaConfiguratorExecuter implements AutomaticallyInstantiatedServiceInterface
{
  public function initialize(): void
  {
    if ($customPostID = $this->getCustomPostID()) {
      $schemaConfigurator = $this->getSchemaConfigurator();
      $schemaConfigurator->executeSchemaConfiguration($customPostID);
    }
  }

  /**
   * Provide the ID of the custom post containing the Schema Configuration block
   */
  abstract protected function getCustomPostID(): ?int;

  /**
   * Initialize the configuration of services before the execution of the GraphQL query
   */
  abstract protected function getSchemaConfigurator(): SchemaConfiguratorInterface;
}

Now, any third-party developer who wants to create their own SchemaConfiguratorExecuter service needs only create a class inheriting from AbstractSchemaConfiguratorExecuter, satisfy the abstract methods, and define the class in their service container configuration.

The service container will then take care of instantiating and initializing the class, as required in the application lifecycle.

Registering but not initializing services

In some situations, we may want to disable a service. In our example PHP app, a GraphQL server for WordPress allows users to remove types from the GraphQL schema. If the blog posts on the website do not show comments, then we can skip adding the Comment type to the schema.

CommentTypeResolver is the service that adds the Comment type to the schema. To skip adding this type to the schema, all we have to do is not register this service in the container.

But by doing so, we run into a problem: if any other service has injected CommentTypeResolver into it (such as this one), then that instantiation would fail because DependencyInjection doesn’t know how to resolve that service and will throw an error:

Fatal error: Uncaught Symfony\Component\DependencyInjection\Exception\RuntimeException: Cannot autowire service "GraphQLAPI\GraphQLAPI\ModuleResolvers\SchemaTypeModuleResolver": argument "$commentTypeResolver" of method "__construct()" references class "PoPSchema\Comments\TypeResolvers\CommentTypeResolver" but no such service exists. in /app/wordpress/wp-content/plugins/graphql-api/vendor/symfony/dependency-injection/Compiler/DefinitionErrorExceptionPass.php:54

That means that CommentTypeResolver and all other services must always be registered in the container service — that is, unless we are absolutely sure it will not be referenced by some other service. As explained below, some services in our example application are only available on the admin side, so we can skip registering them for the user-facing side.

The solution to remove the Comment type from the schema must be to instantiate the service, which should be free of side effects, but not to initialize it, where side effects do happen.

To achieve that, we can use the autoconfigure property when registering the service to indicate that the service must be initialized:

services:
  PoPSchema\Comments\TypeResolvers\CommentTypeResolver:
    class: ~
    autoconfigure: true

And we can update the compiler pass to only inject those services with autoconfigure: true into ServiceInstantiatorInterface:

class AutomaticallyInstantiateServiceCustomPass implements CompilerPassInterface
{
  public function process(ContainerBuilder $container)
  {
    // ...
    foreach ($definitions as $definitionID => $definition) {
      // ...

      if ($definition->isAutoconfigured()) {
        // $definition is a AutomaticallyInstantiatedServiceInterface
        // Do something with it
        $serviceInstantiatorDefinition->addMethodCall(
          'addService',
          [new Reference($definitionID)]
        );
      }
    }
  }
}

Indicating conditional service initialization

The solution above works, but it has a big problem: defining whether the service must be initialized must be set on the service definition file, which is accessed during the container compilation time — i.e., before we can start using the services in our application. We may also want to disable the service based on runtime value in some instances, such as when the admin user disables the Comment type through the application settings, which gets saved in the database.

To solve this issue, we can have the service itself indicate whether it must be initialized. For that, we add the isServiceEnabled method to its interface:

interface AutomaticallyInstantiatedServiceInterface
{
  // ...
  public function isServiceEnabled(): bool;
}

For instance, a service in our example PHP application implements this method like this:

abstract class AbstractScript implements AutomaticallyInstantiatedServiceInterface
{
  /**
   * Only enable the service, if the corresponding module is also enabled
   */
  public function isServiceEnabled(): bool
  {
    $enablingModule = $this->getEnablingModule();
    return $this->moduleRegistry->isModuleEnabled($enablingModule);
  }
}

Finally, the ServiceInstantiatorInterface service can identify those services that must be initialized:

class ServiceInstantiator implements ServiceInstantiatorInterface
{
  // ...

  public function initializeServices(): void
  {
    $enabledServices = array_filter(
      $this->services,
      fn ($service) => $service->isServiceEnabled()
    );
    foreach ($enabledServices as $service) {
      $service->initialize();
    }
  }
}

This way, we are able to skip initializing a service not just when configuring the service container, but also dynamically when running the application.

Registering different container services for different behaviors

PHP applications are not restricted to only one service container. For instance, the app could behave differently depending on a given condition, such as being on the admin side or the user-facing side. This means that, depending on the context, the app will need to register different sets of services.

To achieve this, we can split the services.yaml configuration file into several subfiles and register each of them whenever required.

This definition for services.yaml should always be loaded because it will register all services found under Services/:

services:
  _defaults:
    public: true
    autowire: true

  GraphQLAPI\GraphQLAPI\Services\:
    resource: 'src/Services/*'

And this other definition for Conditional/Admin/services.yaml is a conditional one, loaded only when on the admin side, registering all services found under Conditional/Admin/Services/:

services:
  _defaults:
    public: true
    autowire: true

  GraphQLAPI\GraphQLAPI\Conditional\Admin\Services\:
    resource: 'src/Conditional/Admin/Services/*'

The following code always registers the first file but only registers the second when on the admin side:

self::initServices('services.yaml');
if (is_admin()) {
  self::initServices('Conditional/Admin/services.yaml');
}

Now we must remember that, for production, DependencyInjection will dump the compiled service container into a PHP file. We also need to produce two different dumps and load the corresponding one for each context:

public function getCachedContainerFileName(): string
{
  $fileName = 'container_cache';
  if (is_admin()) {
    $fileName .= '_admin';
  }
  return $fileName . '.php';
}

Establishing convention over configuration

Convention over configuration is the art of establishing norms for a project to apply a standard behavior that not only works, but also reduces the amount of configuration needed by the developer.

Implementations of this strategy may require us to place certain files in certain folders. For instance, to instantiate EventListener objects for some framework, we may be required to place all the corresponding files under an EventListeners folder or assign it the app\EventListeners namespace.

Note how compiler passes can remove such a requirement. To identify a service and treat it in a special way, the service must extend some class, implement some interface, be assigned some service tag, or display some other custom behavior — independently of where it is located.

Thanks to compiler passes, our PHP app can naturally provide convention over configuration for developers creating extensions while reducing its inconveniences.

Exposing information about services through folder structure

Even though we do not need to place files in any particular folder, we could still design a logical structure for the application if it serves some purpose other than initializing the services.

In our example PHP application, let’s have the folder structure convey what services are available, whether they must be implicitly defined in the container, and under what context they will be added to the container.

For that, I’m using the following structure:

  • All facades to access a specific service go under Facades/
  • All services that are always initialized go under Services/
  • All conditional services, which may or may not be initialized depending on the context, go under Conditional/{ConditionName}/Services
  • All implementations of services overriding the default implementation, provided by some packages, go under Overrides/Services
  • All services that are accessed via their contract rather than directly as an implementation, such as service ServiceInstantiatorInterface, can be placed anywhere since their definition in the container must be explicit:
services:
  _defaults:
    public: true
    autowire: true
  PoP\Root\Container\ServiceInstantiatorInterface:
    class: \PoP\Root\Container\ServiceInstantiator

What structure we use is entirely up to us, based on the needs of our application.

Conclusion

Creating a robust architecture for a PHP application, even when it’s only for our own development team, is already a challenge. For these situations, using dependency injection and container services can greatly simplify the task.

On top of that, if we also need to allow third parties — who may not fully understand how the application works — to provide extensions, the challenge gets bigger. When using DependencyInjection component, we can create compiler passes to configure and initialize the application automatically, removing this need from the developer.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Leonardo Losoviz Freelance developer and writer, with an ongoing quest to integrate innovative paradigms into existing PHP frameworks, and unify all of them into a single mental model.

Leave a Reply