Dependency Injection & Containers
Every real class needs help from other objects. An order service needs a database, something that sends SMS messages and perhaps a clock. How that class gets those helpers decides whether your code is easy to test and change, or painful. Dependency injection (DI) is the simple answer used by every modern PHP framework: a class does not create its helpers, it receives them.
In this lesson you will see the problem DI solves, write classes that receive their dependencies through the constructor, swap a real service for a fake one, wire everything together in one place and build a small container that does the wiring for you, the same idea behind the Laravel and Symfony containers.
The problem: classes that build their own helpers
A dependency is any object a class needs to do its job. Here is a common beginner design:
final class OrderService
{
public function confirm(string $mobile, int $orderId): void
{
$pdo = new PDO('mysql:host=localhost;dbname=shop', 'root', 'secret');
$sms = new GatewaySms('live-api-key');
// ... update the order, then send the SMS
}
}It works, but look at what it forces on you:
- You cannot test it without a real database and without sending real SMS messages to real customers.
- You cannot change it without editing the class. Moving to another SMS provider means opening
OrderServiceand every other class that doesnew GatewaySms(...). - Secrets are hard-coded inside business code.
- Every call opens a new database connection.
Constructor injection
The fix is to ask for dependencies in the constructor and let the caller provide them. Combine that with an interface, so the class depends on what it needs ("something that can send an SMS"), not on one particular company's code:
<?php
declare(strict_types=1);
interface SmsSender
{
public function send(string $mobile, string $message): void;
}
final class ConsoleSmsSender implements SmsSender
{
public function send(string $mobile, string $message): void
{
echo "SMS to $mobile -> $message\n";
}
}
final class FakeSmsSender implements SmsSender
{
/** @var list<string> */
public array $sent = [];
public function send(string $mobile, string $message): void
{
$this->sent[] = "$mobile: $message";
}
}
final class OrderService
{
public function __construct(
private SmsSender $sms,
private string $shopName,
) {
}
public function confirm(string $mobile, int $orderId, int $totalRupees): void
{
$message = sprintf('%s: order #%d confirmed, total ₹%s', $this->shopName, $orderId, number_format($totalRupees));
$this->sms->send($mobile, $message);
}
}
// In the app
$service = new OrderService(new ConsoleSmsSender(), 'Kochi Spices');
$service->confirm('98950xxxxx', 1042, 1299);
// In a test: nothing leaves the computer
$fake = new FakeSmsSender();
(new OrderService($fake, 'Kochi Spices'))->confirm('98950xxxxx', 1043, 4500);
print_r($fake->sent);SMS to 98950xxxxx -> Kochi Spices: order #1042 confirmed, total ₹1,299
Array
(
[0] => 98950xxxxx: Kochi Spices: order #1043 confirmed, total ₹4,500
)OrderService has no idea which sender it got. In production you pass the real gateway class, in development a console sender, and in tests a fake that just records messages so the test can check them. That is the whole of dependency injection: pass things in instead of creating them inside. No library is required.
Notice that the shop name is injected too. Configuration values (names, API keys, paths) belong in the constructor, read once from your config. Data that changes per request (the mobile number, the order ID) belongs in method arguments.
The composition root: wiring in one place
If classes no longer create their dependencies, somebody still has to. That place is called the composition root: usually one file such as bootstrap.php that your front controller includes. It reads the config and builds the object graph:
<?php
declare(strict_types=1);
// bootstrap.php
$config = require __DIR__ . '/config/app.php';
$pdo = new PDO($config['db']['dsn'], $config['db']['user'], $config['db']['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$sms = $config['env'] === 'production'
? new GatewaySms($config['sms']['key'])
: new ConsoleSmsSender();
$orders = new OrderService($sms, $config['shop_name']);new for services now appears in one file only. Switching SMS providers is a one-line change here, and the database connection is created once and shared. This manual wiring is perfectly professional for small and medium projects.
A small container with autowiring
As an app grows to dozens of services, the bootstrap file gets long. A DI container is an object that knows how to build services and hands them out on request. The PHP-FIG standard PSR-11 defines the common interface: get(string $id) returns a service and has(string $id) says whether one exists. Here is a small container that also does autowiring: it reads constructor parameter types with reflection and builds the dependencies itself.
<?php
declare(strict_types=1);
final class Container
{
/** @var array<string, Closure> */
private array $factories = [];
/** @var array<string, object> */
private array $instances = [];
public function set(string $id, Closure $factory): void
{
$this->factories[$id] = $factory;
}
public function has(string $id): bool
{
return isset($this->factories[$id]) || class_exists($id);
}
public function get(string $id): object
{
if (isset($this->instances[$id])) {
return $this->instances[$id];
}
if (isset($this->factories[$id])) {
return $this->instances[$id] = ($this->factories[$id])($this);
}
return $this->instances[$id] = $this->autowire($id);
}
private function autowire(string $class): object
{
if (!class_exists($class)) {
throw new RuntimeException("No entry or class found for '$class'");
}
$constructor = (new ReflectionClass($class))->getConstructor();
if ($constructor === null) {
return new $class();
}
$arguments = [];
foreach ($constructor->getParameters() as $parameter) {
$type = $parameter->getType();
if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
throw new RuntimeException("Cannot autowire \${$parameter->getName()} of $class");
}
$arguments[] = $this->get($type->getName());
}
return new $class(...$arguments);
}
}Interfaces cannot be created with new, so you bind them to a factory. Everything else is built automatically:
final class Clock
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-11-20 10:30', new DateTimeZone('Asia/Kolkata'));
}
}
final class BookingNotifier
{
public function __construct(private SmsSender $sms, private Clock $clock)
{
}
public function confirm(string $mobile, int $orderId): void
{
$this->sms->send($mobile, "Order #$orderId confirmed at " . $this->clock->now()->format('H:i'));
}
}
$container = new Container();
$container->set(SmsSender::class, fn() => new ConsoleSmsSender());
$notifier = $container->get(BookingNotifier::class);
$notifier->confirm('98950xxxxx', 1042);
var_dump($notifier === $container->get(BookingNotifier::class));
try {
$container->get('Mailer');
} catch (RuntimeException $e) {
echo $e->getMessage(), "\n";
}SMS to 98950xxxxx -> Order #1042 confirmed at 10:30 bool(true) No entry or class found for 'Mailer'
The container saw that BookingNotifier needs a SmsSender (bound to ConsoleSmsSender) and a Clock (a plain class it can build), created both and passed them in. It also remembers each service, so the second get() returns the same object. Scalar parameters such as string $shopName cannot be guessed, which is why real containers let you configure them, as the factory closures do here.
Containers in real frameworks
You will rarely write your own container at work. The ideas map directly onto the tools you will meet:
| Tool | How you bind an interface | Autowiring |
|---|---|---|
| Laravel service container | $this->app->bind(SmsSender::class, GatewaySms::class) in a service provider | Yes, from constructor types |
| Symfony DependencyInjection | Service definitions in config/services.yaml, interfaces aliased automatically when one class implements them | Yes, autowire: true |
| PHP-DI | Definitions array passed to its container builder | Yes |
In a Laravel controller you simply type public function __construct(private OrderService $orders) and the framework does the rest. Knowing it is ordinary constructor injection underneath makes framework code far less magical. See Laravel vs Symfony: Choosing a Framework for how the two frameworks compare.
Common mistakes
- Using
newfor services inside business classes. Ask for the service in the constructor instead. Creating simple value objects (new DateTimeImmutable(),new Money(...)) inside methods is fine. - Static helpers and global variables such as
DB::query()orglobal $pdo. They are hidden dependencies that tests cannot replace. - Huge constructors. A class that needs eight services is probably doing eight jobs. Split it.
- Injecting request data into services.
$_POSTvalues are method arguments, not constructor dependencies.
Key points
- A dependency is an object a class needs; dependency injection means passing it in rather than creating it inside.
- Prefer constructor injection and type against interfaces, so real, fake and future classes can be swapped.
- Build the object graph in one composition root, reading configuration once.
- A DI container builds and shares services on request; PSR-11 standardises
get()andhas(). - Autowiring uses reflection on constructor types; interfaces and scalar values still need bindings.
- Do not use the container as a service locator inside your classes.
Exercise
Add a factory() method to the Container class that registers a closure whose result is not remembered, so every get() call returns a new object. Register a Ticket class this way (give it a random six-character code in its constructor using bin2hex(random_bytes(3))) and show that two get(Ticket::class) calls return different objects, while get(Clock::class) still returns the same one.
Show solution
// Inside Container
/** @var array<string, true> */
private array $notShared = [];
public function factory(string $id, Closure $factory): void
{
$this->factories[$id] = $factory;
$this->notShared[$id] = true;
}
// In get(), replace the factory branch with:
if (isset($this->factories[$id])) {
$object = ($this->factories[$id])($this);
if (!isset($this->notShared[$id])) {
$this->instances[$id] = $object;
}
return $object;
}final class Ticket
{
public string $code;
public function __construct()
{
$this->code = bin2hex(random_bytes(3));
}
}
$container = new Container();
$container->factory(Ticket::class, fn() => new Ticket());
var_dump($container->get(Ticket::class) === $container->get(Ticket::class));
var_dump($container->get(Clock::class) === $container->get(Clock::class));bool(false) bool(true)
Shared services are stored in $instances; factory services skip that step, so each call runs the closure again.
Quick Quiz
1. What is dependency injection?
The class receives its collaborators instead of creating them, which makes them easy to swap.
2. Why does OrderService type its constructor parameter as the SmsSender interface?
Depending on an interface keeps the service independent of one provider and easy to test.
3. What can an autowiring container NOT work out on its own?
Interfaces and scalar values need an explicit binding or factory.
4. Which pattern should you avoid?
That is the service locator style, which hides real dependencies.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
