PHP DI Containers for People Who Say, 'Why Not Just Switch with .env?'
Contents
Why Do We Want to Swap Dependencies in the First Place?
Modern programs are not written in one file anymore. Like it or not, object-oriented programming is the norm, which means relationships between classes inevitably get complicated.
OrderService -> UserService -> UserRepository -> PDO
-> PaymentService -> PaymentGateway
-> NotificationService -> MailClient
The goal is to “cut through” this tangled dependency graph and make each class testable on its own.
// Tightly coupled: testing UserService requires MySQL
class UserService
{
private MySQLUserRepository $repository;
public function __construct()
{
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'password');
$this->repository = new MySQLUserRepository($pdo);
}
}
With this, you need a real MySQL instance just to test UserService. That is annoying in CI, and you have to worry about database state every time you run tests.
// Loosely coupled: tests can inject InMemoryRepository
class UserService
{
public function __construct(
private UserRepositoryInterface $repository
) {}
}
If you accept dependencies through interfaces, tests can pass InMemoryUserRepository instead. It works without a database, runs fast, and lets you reset state per test case.
That is the core benefit of loose coupling. As long as you have test data, you can make things predictable. Unit tests, integration tests, and E2E tests all become easier.
The real question is: how do you swap them?
”Why Not Just Switch with .env?”
When I talk about DI containers, people sometimes say:
“If you want different implementations for production and tests, why not just branch on .env?”
Sure, you can. But once you actually try it, hell awaits.
The Hell of Doing It with .env
Conditionals Scatter Everywhere
class UserService
{
private UserRepositoryInterface $repository;
public function __construct()
{
if (getenv('APP_ENV') === 'test') {
$this->repository = new InMemoryUserRepository();
} else {
$pdo = new PDO(
getenv('DB_DSN'),
getenv('DB_USER'),
getenv('DB_PASS')
);
$this->repository = new MySQLUserRepository($pdo);
}
}
}
If there are 10 classes using UserRepository, do you write this branch 10 times?
Every New Dependency Means Editing Everything
Want to add CacheInterface? Do we add another branch to every class?
public function __construct()
{
// UserRepository branch...
if (getenv('APP_ENV') === 'test') {
$this->repository = new InMemoryUserRepository();
} else {
// ...
}
// Add cache branch too...
if (getenv('APP_ENV') === 'test') {
$this->cache = new ArrayCache();
} else {
$this->cache = new RedisCache(getenv('REDIS_HOST'));
}
}
That gets old fast.
Dependencies of Dependencies Become a Problem
Real applications have deep dependency chains.
OrderService
-> UserService
-> UserRepository
-> PDO
If you wire that by hand:
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));
$userRepo = new MySQLUserRepository($pdo);
$userService = new UserService($userRepo);
$orderService = new OrderService($userService);
Do you write this everywhere OrderService is needed?
If you want to swap only UserRepository in tests, do you rewrite the whole thing?
”Why Not Put It in a Config File?”
“Then put everything in a config file and the scattering problem is solved, right?”
// config.php
function createPdo(): PDO
{
static $pdo = null;
if ($pdo === null) {
$pdo = new PDO(
getenv('DB_DSN'),
getenv('DB_USER'),
getenv('DB_PASS')
);
}
return $pdo;
}
function createUserRepository(): UserRepositoryInterface
{
if (getenv('APP_ENV') === 'test') {
return new InMemoryUserRepository();
}
return new MySQLUserRepository(createPdo());
}
function createUserService(): UserService
{
return new UserService(createUserRepository());
}
function createOrderService(): OrderService
{
return new OrderService(createUserService());
}
Yes, the branches are now in one place. But:
- Every new class means editing the config: create
PaymentService, addcreatePaymentService() - Any dependency change means editing the config: add
CacheInterfacetoUserService, then updatecreateUserService() - Singleton management is manual: you end up writing things like
static $pdoyourself - Humans have to keep the whole dependency graph in their heads
In the end, humans are still manually managing the dependency graph.
A DI container can resolve dependencies automatically as long as the types match. Add a new class or change a constructor, and as long as the bindings are in place, it keeps working.
”Isn’t It the Same If You Still Write Namespaces?”
“DI containers still need binding config with class names, right? Isn’t that the same as a config file?”
The amount of writing is very different.
Config-file style:
// Write factories for every class
function createPdo(): PDO { ... }
function createUserRepository(): UserRepositoryInterface { ... }
function createUserService(): UserService { ... }
function createOrderService(): OrderService { ... }
function createPaymentService(): PaymentService { ... }
function createNotificationService(): NotificationService { ... }
// This keeps growing as classes are added...
DI container:
// Only map interfaces to implementations
$container->singleton(PDO::class, fn() => new PDO(...));
$container->singleton(UserRepositoryInterface::class, MySQLUserRepository::class);
$container->singleton(CacheInterface::class, RedisCache::class);
// You do not need to bind concrete-class dependencies
You do not need to bind UserService or OrderService. Their constructors are resolved automatically from the types.
The only things that need explicit bindings are:
- Interface -> implementation mappings
- Things that need special initialization, like PDO connection info