PHP Dependency Injection with PSR-11
Modern PHP development emphasizes modularity, testability, and maintainability. Dependency Injection (DI) is a fundamental design pattern that addresses these concerns by allowing the dependencies of a class to be injected from the outside, rather than being created internally. This approach drastically improves code flexibility and simplifies unit testing. Coupled with standardized interfaces like PSR-11, DI becomes even more powerful, fostering interoperability across different frameworks and libraries. This post will explore the core concepts of Dependency Injection, the role of Service Containers, and how PSR-11 provides a universal interface for managing dependencies in your PHP applications.
Understanding Dependency Injection
At its heart, Dependency Injection is about inverting control over how a class obtains its collaborators (dependencies). Instead of a class being responsible for creating its own instances of other classes it needs, those instances are "injected" into it. This can happen through the constructor (constructor injection), setter methods (setter injection), or interface methods (interface injection).
Consider a simple Mailer
class that needs a Logger
:
Without Dependency Injection
<?php
class Mailer
{
private $logger;
public function __construct()
{
$this->logger = new FileLogger('/var/log/mailer.log');
}
public function sendEmail(string $to, string $subject, string $body)
{
// ... send email logic ...
$this->logger->log('Email sent to ' . $to);
}
}
// Usage
$mailer = new Mailer();
$mailer->sendEmail('[email protected]', 'Hello', 'Test email');
In this example, Mailer
is tightly coupled to FileLogger
. If you wanted to switch to a DatabaseLogger
or a NullLogger
for testing, you'd have to modify the Mailer
class itself.
With Dependency Injection (Constructor Injection)
<?php
interface LoggerInterface
{
public function log(string $message);
}
class FileLogger implements LoggerInterface
{
private $filePath;
public function __construct(string $filePath)
{
$this->filePath = $filePath;
}
public function log(string $message)
{
file_put_contents($this->filePath, $message . PHP_EOL, FILE_APPEND);
}
}
class Mailer
{
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
public function sendEmail(string $to, string $subject, string $body)
{
// ... send email logic ...
$this->logger->log('Email sent to ' . $to);
}
}
// Usage
$logger = new FileLogger('/var/log/app.log');
$mailer = new Mailer($logger);
$mailer->sendEmail('[email protected]', 'Hello', 'Test email');
Here, Mailer
depends on an abstraction (LoggerInterface
), and the concrete Logger
implementation is passed in. This makes Mailer
reusable and testable with different Logger
implementations without modification.
The Role of Service Containers
While manual dependency injection works for small applications, managing a large number of dependencies and their creation can become cumbersome. This is where a Service Container (also known as a Dependency Injection Container or DIC) comes into play. A Service Container is an object that knows how to instantiate and configure objects and their dependencies.
Instead of manually instantiating every dependency, you register them with the container. When you need an object, you ask the container for it, and it handles the creation and injection of all necessary dependencies.
Key benefits of Service Containers:
- Centralized Configuration: Define how objects are created and configured in one place.
- Automatic Dependency Resolution: The container automatically resolves and injects dependencies.
- Singleton Management: Easily manage shared instances of objects.
- Lazy Loading: Objects are only instantiated when they are actually needed.
- Testability: Simplifies swapping out dependencies for mocks or stubs during testing.
Popular PHP Service Containers include Symfony's DependencyInjection component, PHP-DI, and Laravel's Service Container.
PSR-11: Container Interface
Prior to PSR-11, every Dependency Injection Container had its own way of defining and retrieving services, leading to vendor lock-in and difficult-to-reuse code. PSR-11, published by the PHP Framework Interoperability Group (PHP-FIG), standardizes the interface for containers, providing two simple interfaces:
Psr\Container\ContainerInterface
Psr\Container\NotFoundExceptionInterface
ContainerInterface
The ContainerInterface
defines two methods:
get(string $id)
: Finds an entry of the container by its identifier and returns it. If the identifier is not found, aNotFoundExceptionInterface
is thrown.has(string $id)
: Returnstrue
if the container can return an entry for the given identifier;false
otherwise.
<?php
namespace Psr\Container;
interface ContainerInterface
{
/**
* Finds an entry of the container by its identifier and returns it.
*
* @param string $id Identifier of the entry to look for.
* @throws NotFoundExceptionInterface No entry was found for **this** identifier.
* @throws ContainerExceptionInterface Error while retrieving the entry.
* @return mixed Entry.
*/
public function get(string $id);
/**
* Returns true if the container can return an entry for the given identifier.
* Returns false otherwise.
*
* `has($id)` returning true does not mean that `get($id)` will not throw an exception.
* It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`.
*
* @param string $id Identifier of the entry to look for.
* @return bool
*/
public function has(string $id);
}
NotFoundExceptionInterface
This interface simply extends Psr\Container\ContainerExceptionInterface
(which itself extends Throwable
) and signifies that the requested service was not found in the container.
By adhering to PSR-11, different DI containers can be swapped out without affecting the application code that interacts with them. This promotes a truly decoupled architecture.
Building a Simple PSR-11 Compliant Container
Let's create a very basic container that adheres to the PSR-11 standard.
First, you'll need to install the PSR-11 interfaces via Composer:
composer require psr/container
Now, implement a simple container:
<?php
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
class SimpleContainerException extends \Exception implements \Psr\Container\ContainerExceptionInterface {}
class SimpleNotFoundException extends SimpleContainerException implements NotFoundExceptionInterface {}
class SimpleContainer implements ContainerInterface
{
private array $definitions = [];
private array $instances = [];
public function set(string $id, callable $definition):
{
$this->definitions[$id] = $definition;
}
public function get(string $id)
{
if (!$this->has($id)) {
throw new SimpleNotFoundException(sprintf('Service with ID "%s" not found.', $id));
}
if (!isset($this->instances[$id])) {
$this->instances[$id] = $this->definitions[$id]($this);
}
return $this->instances[$id];
}
public function has(string $id): bool
{
return isset($this->definitions[$id]);
}
}
// Define some services
$container = new SimpleContainer();
$container->set('logger', function ($c) {
return new FileLogger('/tmp/app.log');
});
$container->set('mailer', function ($c) {
return new Mailer($c->get('logger'));
});
// Retrieve and use services
$mailer = $container->get('mailer');
$mailer->sendEmail('[email protected]', 'Hello!', 'This is a test email from a PSR-11 container.');
try {
$nonExistentService = $container->get('nonExistent');
} catch (SimpleNotFoundException $e) {
echo "\nError: " . $e->getMessage() . "\n";
}
In this example:
- We define a
SimpleContainer
that implementsContainerInterface
. - The
set
method (our custom addition) allows us to register service definitions as closures. - The
get
method retrieves an instance, instantiating it only if it hasn't been already (basic singleton behavior). has
checks for the existence of a service definition.- Custom exceptions
SimpleContainerException
andSimpleNotFoundException
are created to fulfill the PSR-11 exception requirements.
Best Practices with DI and PSR-11
- Favor Constructor Injection: It makes dependencies explicit and ensures that an object is in a valid state upon creation.
- Depend on Abstractions: Always type-hint interfaces (
LoggerInterface
) rather than concrete implementations (FileLogger
). This is the core of flexible and testable code. - Keep Container Simple: The container's primary job is to wire up dependencies. Avoid putting business logic inside service definitions.
- Avoid Service Location: While the container allows you to
get
services anywhere, resist the urge to pass the container itself into other objects. This leads to the "Service Locator" anti-pattern, which reintroduces tight coupling to the container. Let the container inject what's needed. - Use Existing Libraries: For production applications, leverage robust, well-tested DI containers like Symfony's DependencyInjection, PHP-DI, or Laravel's IoC container. They offer advanced features like auto-wiring, compilation, and comprehensive error handling.
Conclusion
Dependency Injection, augmented by the PSR-11 Container Interface, is a cornerstone of modern, maintainable, and testable PHP applications. By separating object creation from object usage, you gain significant flexibility and reduce coupling. PSR-11 ensures that your application remains portable and compatible with various container implementations, allowing you to choose the best tool for your project without fear of vendor lock-in. Embrace these patterns to build more robust and scalable PHP systems.
Resources
- PSR-11: Container interface: https://www.php-fig.org/psr/psr-11/
- PHP-DI: https://php-di.org/
- Symfony DependencyInjection Component: https://symfony.com/doc/current/components/dependency_injection.html
Next Steps
- Experiment with a full-featured DI container like PHP-DI in a small project.
- Explore auto-wiring and how it simplifies dependency configuration.
- Read more about the "Service Locator" anti-pattern and why it should be avoided.