league/container
League/Container is a fast, lightweight, PSR-11 compliant dependency injection container for PHP. It offers constructor autowiring, service providers, inflectors, and lazy loading out of the box, requires no configuration, and supports PHP 8.3, 8.4, and 8.5.
league/container is a standalone PSR-11 container. In a Laravel codebase the framework container (Illuminate\Container\Container) is already doing this job, so the honest first question is "why add a second container?" — the answer is almost always isolation: a package you intend to publish, a bounded-context module, a CLI/queue script, or a unit test that shouldn't boot the app.
composer require league/container # ^5.x requires PHP >= 8.1 (current README targets 8.3–8.5)
Minimal bootstrap — note that auto-wiring is off by default, which is the single most common source of "why can't it find my class?" confusion:
use League\Container\Container;
use League\Container\ReflectionContainer;
$container = new Container();
$container->delegate(new ReflectionContainer(cacheResolutions: true));
$container->add(UserRepository::class); // transient
$container->addShared(ReportService::class); // singleton
$container->add(PublisherInterface::class, KafkaPublisher::class);
$service = $container->get(ReportService::class); // same instance every call
Where to look first
docs/ in the repo (rendered at container.thephpleague.com/5.x/) — the definition, delegation, service-provider and events pages are the ones you'll actually reread.changelog/5.0.0.md → 5.2.0.md. The 5.x line changed enough (overwriting opt-in, events, attribute resolution, inflector() deprecation) that any tutorial older than 5.0 will mislead you.CHANGELOG for anything older only if you're maintaining a legacy package.First use case to try (Laravel-flavoured): take one module of your app — say app/Domain/Billing — and give it its own container wired in a Laravel service provider. You get constructor injection without the app() helper creeping into domain code, and the module becomes liftable into another project.
// app/Providers/BillingServiceProvider.php
public function register(): void
{
$c = new Container();
$c->delegate(new ReflectionContainer(cacheResolutions: true));
$c->delegate($this->app); // fall back to Laravel for anything unregistered
$c->addShared(Gateway::class, fn () => new StripeGateway(config('billing.key')));
$c->addShared(InvoiceIssuer::class);
$this->app->instance(Container::class, $c);
}
add vs addShared, and defaulting to shared$container->add(Handler::class); // new instance each get()
$container->addShared(Handler::class); // memoised
// Or flip the default and opt out per definition:
$container->defaultToShared();
$container->add(ScratchPad::class)->setShared(false);
Treat defaultToShared() as an architectural decision: stateless services are fine shared, anything holding request/user state is not.
$container->add(MailerInterface::class, SmtpMailer::class);
$container->add(SmtpMailer::class)
->addArgument(new LiteralArgument('smtp.example.com'))
,->addArgument(25);
Because 5.2 makes an interface definition resolve through the concrete's own definition (rather than reflecting it directly), registering the concrete separately is worth doing — you get one instance, not two.
use League\Container\ServiceProvider\AbstractServiceProvider;
use League\Container\ServiceProvider\BootableServiceProviderInterface;
class BillingProvider extends AbstractServiceProvider implements BootableServiceProviderInterface
{
public function provides(string $id): bool
{
return in_array($id, [Gateway::class, InvoiceIssuer::class], true);
}
public function register(): void
{
$this->getContainer()
->addShared(Gateway::class, fn () => new StripeGateway($_ENV['STRIPE_KEY']))
->addTag('billing');
$this->getContainer()->addShared(InvoiceIssuer::class);
}
public function boot(): void { /* runs eagerly, after register */ }
}
$container->addServiceProvider(new BillingProvider());
provides() matters: it's what has() and lazy registration consult. Lying about it (declaring a service you don't register) historically produced stack overflows — fixed, but keep it truthful. Registering the same provider class twice requires a signature (v2.2+), so key them deliberately.
Delegates are consulted whenever the container can't resolve locally, in order. This is how you compose league/container with the rest of a Laravel app:
$container->delegate(new ReflectionContainer(cacheResolutions: true)); // auto-wiring
$container->delegate($laravelApp); // app bindings
$container->delegate(new SomeLegacyContainer());
// 5.2+: fetch one back by type
$reflection = $container->getDelegate(ReflectionContainer::class);
A pragmatic pattern: keep league/container authoritative for your module's classes, delegate to Laravel for framework services (LoggerInterface, Dispatcher, config wrappers). That way the module's own graph stays explicit while framework glue still resolves.
Reflection can't know whether 'smtp.example.com' is a value or a service id, so v4+ gives you wrappers:
use League\Container\Argument\{LiteralArgument, ResolvableArgument};
$container->add(Client::class)
->addArgument(new LiteralArgument('https://api.example.com')) // never resolved
->addArgument(new ResolvableArgument(HttpClient::class)); // force resolution
RawArgument still works (and since 2.4 can wrap shared objects), but new code should prefer the explicit wrappers — they read better in review.
5.1 adds attribute-based resolution for the cases reflection genuinely can't disambiguate:
class ReportBuilder
{
public function __construct(
#[Inject(ReportRepository::class)] private RepositoryInterface $repo,
) {}
}
Tag definitions you need to iterate over (route registrars, health checks, event subscribers):
$container->add(CheckDatabase::class)->addTag('health');
$container->add(CheckRedis::class)->addTag('health');
foreach ($container->tagged('health') as $definition) { /* ... */ }
(Verify the accessor name against your installed minor — the tag API has been stable since 3.0 but the container-level helper has been renamed around.) Note that 5.2 automatically tags shared definitions with 'shared', which is handy for sweeping all singletons.
call() for invokable controllers and command handlers$response = $container->call([ReportController::class, 'show'], ['id' => 42]);
$response = $container->call(fn (ReportService $s) => $s->summary());
call() proxies to ReflectionContainer, resolving by type first, then by parameter name, then falling back to defaults — and it accepts named arguments at runtime, which makes it a decent stand-in for a router's dispatcher in a framework-agnostic package.
extend() and afterResolve()$container->extend(LoggerInterface::class, function (LoggerInterface $logger, Container $c) {
return new BufferedLogger($logger);
});
// 5.2+: replaces inflector() for "do X to every resolved instance of type Y"
$container->afterResolve(Handler::class, function (Handler $handler) use ($container) {
$handler->setLogger($container->get(LoggerInterface::class));
});
For anything more selective, the 5.2 event system (OnDefineEvent, BeforeResolveEvent, DefinitionResolvedEvent, ServiceResolvedEvent) with forType(), forId(), forTag() and where() filters is the extension point to reach for. Dispatch is lazy — no listeners, no overhead — and EventDispatcher::hasListenersFor() lets you branch on it.
protected function setUp(): void
{
$this->container = new Container();
$this->container->delegate(new ReflectionContainer(cacheResolutions: false));
// cacheResolutions: false keeps mutations after first resolve visible in tests
}
Resolve the top-level object and assert on behaviour; substitute fakes with add(Foo::class, fn () => $fake). Never share the container instance across tests — a shared definition is a leaked global.
Auto-wiring is opt-in. Without ->delegate(new ReflectionContainer()), get(Foo::class) throws NotFoundException even though Foo exists. This is the #1 "the package is broken" report. Adding the delegate is a one-liner; forgetting it costs an hour.
cacheResolutions: true is a double-edged sword. It avoids repeated reflection, but once a class is resolved through the cache, re-registering or extending its definition later in the request won't take effect. Enable in production, disable in tests.
Strings as arguments are ambiguous. ->addArgument('Foo\Bar') is treated as a service id, not a string. Since v4, use LiteralArgument for values. The 1.3.1 fix ("arbitrary values attempted to be resolved as classes") is exactly this class of bug in its older form.
Overwriting is disabled in 5.x. A second add() for an existing id no longer silently replaces — you must opt in. If you're migrating from 4.x and relying on last-registration-wins, you'll get exceptions. Check the add() signature in your installed version rather than trusting a blog post.
Naming collisions with Laravel. ServiceProvider, register(), boot(), Container all mean something different in each world. Alias imports (use League\Container\Container as LeagueContainer;) and give your bridge classes obviously-leaguey names. Also note ContainerAwareTrait exposes {set,get}LeagueContainer — it will not help you reach Laravel's app().
Don't create parallel singletons. If ReportService is addShared in league/container and bound as a singleton in Laravel, you get two instances with independent state. Pick one owner per service and delegate the rest.
Union types are explicitly not auto-wired (5.1 changelog). __construct(Foo|Bar $x) will fail reflection resolution — name it with #[Inject] or pass a ResolvableArgument.
Circular dependencies. The container guards against recursion (4.2.1 fixed an infinite loop in definition resolution), but the error message is not always enlightening. Attach a BeforeResolveEvent listener that logs the id chain while debugging rather than sprinkling dump() calls.
Unsatisfied constructor deps in 5.2 throw ContainerException with guidance instead of a raw ArgumentCountError. In older versions a missing scalar surfaced as a fatal — if you see that, upgrade before debugging your own code.
Catch PSR exceptions, not League ones. For portability, catch Psr\Container\NotFoundExceptionInterface / ContainerExceptionInterface. has() consults delegates as well, so has(X) true with a failing get(X) is possible when a delegate's has() is optimistic (the "provider lies about providing a service" bug class, fixed in 3.3.0 but worth knowing conceptually).
Service providers are instantiated lazily but stored. Since 1.4 they're kept after first instantiation, and since 3.0 pullable via get(Provider::class). Bootable ones run eagerly — put expensive work in register(), not boot().
inflector() is deprecated in 5.2 and disappears in 6.0. Migrate to afterResolve() now; it's a mechanical change and saves a future breaking-change scramble.
Extension point checklist: attributes → events → delegates → extend() → custom ServiceProvider. Reach down that list in order; most "I need to hook the container" problems are solved by afterResolve() or a filtered event listener, not by subclassing Container.
Static analysis: the package runs PHPStan level 8 in CI, so get()'s generic hints (where present) are trustworthy — but PSR-11's get() returns mixed at the interface level. Add @var annotations or thin typed accessors in your own code rather than sprinkling assert()s.
How can I help you explore Laravel packages today?