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.
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
:
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();
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.
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)] ); } } } }
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.
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'; }
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.
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:
Facades/
Services/
Conditional/{ConditionName}/Services
Overrides/Services
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.
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.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]