mnapoli/php-di
PHP-DI is a practical, feature-rich dependency injection container for PHP. It offers autowiring, constructor injection, flexible definitions, lazy dependencies, and PSR-11 compatibility, helping you build cleaner, more testable applications with minimal configuration and boilerplate.
PHP-DI is a framework-agnostic, PSR-11 container with autowiring on by default. In Laravel you already have a container, so treat PHP-DI as the tool for the inside of a package, a CLI worker, a queue consumer, or any code you want to be runnable outside artisan.
composer require php-di/php-di
Your entire entry point is a ContainerBuilder. Build once at the front controller / bootstrap, nowhere else:
// bootstrap.php
use DI\ContainerBuilder;
$builder = new ContainerBuilder();
$builder->addDefinitions(__DIR__ . '/config/di.php');
$builder->useAttributes(true); // opt in to #[Inject] / #[Injectable]
if (getenv('APP_ENV') === 'production') {
$builder->enableCompilation(__DIR__ . '/var/cache/php-di');
$builder->writeProxiesToFile(true, __DIR__ . '/var/cache/php-di');
}
return $builder->build();
First use case — a class with typed constructor dependencies needs zero configuration:
final class InvoiceMailer
{
public function __construct(
private Mailer $mailer, // resolved if Mailer is a concrete class
private InvoiceRepository $repo,
) {}
}
$container = require __DIR__ . '/bootstrap.php';
$container->get(InvoiceMailer::class); // fully wired, no definitions written
Where to look first: the "Definitions" doc page, then src/ContainerBuilder.php for the available toggles, then the compiled cache file the first time you enable compilation — it is the most readable explanation of what the container actually resolved.
The rule that keeps PHP-DI pleasant: autowire everything you can, define only the edges — interfaces, scalars, third-party classes you don't own, and anything with a factory.
<?php
// config/di.php
use function DI\autowire;
use function DI\create;
use function DI\env;
use function DI\factory;
use function DI\get;
return [
// 1. Config values as flat keys
'db.dsn' => env('DB_DSN'),
'db.user' => env('DB_USER', 'app'),
// 2. Interface -> implementation alias
LoggerInterface::class => get(MonologLogger::class),
CacheInterface::class => get(RedisCache::class),
// 3. Concrete class with scalar params
PdoConnection::class => autowire()
->constructorParameter('dsn', get('db.dsn'))
->constructorParameter('user', get('db.user')),
// 4. Third-party object (no autowiring possible)
Redis::class => factory(function (ContainerInterface $c) {
$redis = new Redis();
$redis->connect($c->get('redis.host'), 6379);
return $redis;
}),
];
create() (new blank object, then set properties) vs autowire() (autowire remaining params) vs factory() (callable decides everything) vs get() (alias to another entry) covers ~95% of real definitions.
Closures in definitions are the main thing that fights you in compiled mode. Prefer a factory class — PHP-DI autowires its arguments and calls the method:
final class RedisFactory
{
public function __construct(private Config $config) {}
public function create(): Redis { /* ... */ }
}
// definition
return [
Redis::class => factory([RedisFactory::class, 'create']),
];
Container::call() — injection for callablesThe most underused feature. Route handlers, console commands, queue jobs, event listeners — anything you'd otherwise pass $container into:
$container->call(RouteHandler::class); // invokable class
$container->call([ReportController::class, 'show'], ['id' => 42]);
$container->call(function (InvoiceMailer $m, string $to) { ... }, ['to' => '[email protected]']);
Explicit parameters win over container entries, so this doubles as a mini dispatcher.
use DI\Attribute\Inject;
final class ReportGenerator
{
#[Inject(lazy: true)] // proxy, not a real instance
private PdfRenderer $renderer;
// or in definitions: PdfRenderer::class => DI\lazy()
}
Lazy proxies break circular dependencies (A -> B -> A) without a service locator, and take the cost of PdfRenderer off every request that never renders.
use function DI\decorate;
return [
LoggerInterface::class => decorate(function (LoggerInterface $inner, ContainerInterface $c) {
return new CorrelatedLogger($inner, $c->get(RequestId::class));
}),
];
The previous definition is passed in, so several packages can each add a decorator without knowing about each other.
PHP-DI's container is PSR-11, and Laravel's is too, so you can let PHP-DI fall back to Laravel's container for anything it doesn't know:
$builder->wrapContainer(app()); // Illuminate\Container\Container implements PSR-11
Practical shape: keep the Laravel app container for HTTP/controllers/ORM; build a PHP-DI container per package that pulls framework services through wrapContainer() and is bootable in a unit test with no Laravel app at all. If you inject ContainerInterface into app code, inject it as Psr\Container\ContainerInterface so you can swap either side.
Never mock the container; rebuild it with overrides:
$builder = (new ContainerBuilder())->addDefinitions(require 'config/di.php');
$builder->addDefinitions([
Mailer::class => new FakeMailer(),
]);
$service = $builder->build()->get(InvoiceMailer::class);
For a live container (non-compiled), $container->set(Mailer::class, new FakeMailer()) works too — but only before enableCompilation(), since compiled containers are read-only.
Entries are shared, including factory results. $container->get() caches. If you need a fresh object graph per call — per job, per row, per request — use $container->make(Foo::class) (PHP-DI extension, not PSR-11) or inject a factory service you then call. A factory() definition that return new Foo() does not give you a new instance on every get(), which surprises nearly everyone once.
Scalar parameters are the only thing autowiring can't solve. A string $dsn with no default throws Parameter $dsn of __construct() has no value defined or guessable. Either give it a default, or define the class with autowire()->constructorParameter(...). Nullable params silently become null — a missing definition for ?LoggerInterface will fail quietly, so prefer required types.
Interfaces must be aliased, always. Autowiring will not guess LoggerInterface -> MonologLogger. Error text is No definition or class found for "App\LoggerInterface" — that message means "you forgot an alias", not "the container is broken".
Compilation is a prod-only switch with three traps:
$container->set() and $container->has() behave differently in compiled mode. Run tests against an uncompiled container.Lazy proxies need non-final classes. DI\lazy()/#[Inject(lazy: true)] generate a subclass; a final class will fail at proxy generation, usually only in production because dev never hits that path. In prod also call writeProxiesToFile(true, $dir) — otherwise proxies are generated to a temp stream on every request.
Attributes need two things: $builder->useAttributes(true), and the object must be created by the container. #[Inject] on a property of an object you new'd yourself does nothing — no error, just a null property. For legacy objects, $container->injectOn($object) performs property/method injection explicitly. (Doctrine-style @Inject annotations live in a separate package in v7 — don't mix the two syntaxes in the same codebase.)
env() reads getenv()/$_ENV/$_SERVER at resolution time. Under php-fpm with clear_env = on or under a process supervisor, env vars may simply not be visible — env('DB_DSN') then yields null and you get a confusing downstream type error. Always supply the second default argument, or resolve config yourself and inject plain values.
Circular dependencies throw, and the message names the cycle. Break it with lazy injection rather than by injecting the container — ContainerInterface injected into domain code is the thing PHP-DI exists to prevent. Reserve it for genuinely optional/dynamic lookups and for lazy proxies.
Debugging checklist: $container->has(SomeClass::class) before blaming autowiring; read the exception — PHP-DI's traces usually name the exact parameter and the definition chain; open the compiled container file to see what was actually resolved; and $container->getKnownEntries(...)/the definitions array is the ground truth for what exists.
Extension points worth knowing: addDefinitions() is additive and callable-friendly (register per-package definition files, in order — later wins), useAutoWiring(false) to force explicitness in a strict codebase, and decorate() for cross-cutting wrappers. Resist the urge to build a "container module" abstraction on top; a plain array per package plus decorate() scales further than most homegrown registries.
How can I help you explore Laravel packages today?