In one of my previous blogs I have built Hobby, a simple experiment that shows how Laravel jobs are implemented. When you look at that code, it is obvious that the Laravel code that would do the same thing is much more elegant and simple. For example, in Laravel you can write a job like this:
public function handle(Mailer $mailer): void
{
$mailer->send(...);
}
and the mailer just appears. The same thing happens in controllers, commands, listeners, middlewares and many other places. All of that is done by the service container, which is probably the most important part of the framework, and the one we use every day without thinking about it. So I decided to build a small one for Hobby and see how it works.
The goal of this experiment is not to build a production-grade Laravel clone, but to understand the main ideas behind it.
The problem
In Hobby, the hobbyist executed hobbies like this:
$hobby->handle();
That only works if handle() has no parameters. So if a hobby needs some service, for example a mailer, it has to create it by itself:
#[OnQueue('container-demo')]
final readonly class ManualWelcomeEmailHobby implements Hobby
{
public function __construct(
private string $email,
) {}
public function handle(): void
{
$mailer = new FakeMailer();
$mailer->sendWelcomeEmail($this->email);
}
}
This looks fine for now, but the hobby decides how to create the service. If FakeMailer later needs config, a logger or an HTTP client, it becomes something like this:
$mailer = new FakeMailer(
new Logger(),
new HttpClient(),
'smtp.example.com',
);
And every hobby that sends an email would repeat that. What we want instead is a hobby that only says what it needs, and something else decides how to create it. That something else is the container.
Short note on dependency injection vs service location
Dependency injection means that dependencies are passed into the class, rather than the class creating them itself:
class UserController
{
public function __construct(private UserService $service) {}
}
Service location means that the class asks for the dependency by itself:
class UserController
{
public function __construct()
{
$this->service = app(UserService::class);
}
}
Dependency injection is usually preferred because it makes dependencies explicit and the class is easier to test. The container we are building supports dependency injection, and in Laravel the same container is also used for service location through app() and facades.
Let's start coding
Our container will have five public methods:
class Container
{
public function bind(string $abstract, string|callable $concrete): void {}
public function singleton(string $abstract, string|callable $concrete): void {}
public function instance(string $abstract, object $instance): void {}
public function make(string $abstract): object {}
public function call(object $object, string $method = 'handle'): mixed {}
}
$abstract is the thing we are asking for, usually a class or an interface name, and $concrete is how we create it. It can be a class name or a closure.
make()
make() is the heart of the container. We give it a class name and it gives us back an object, with all of its dependencies already created. The simplest version would look something like this:
public function make(string $abstract): object
{
$concrete = $this->bindings[$abstract] ?? $abstract;
if (is_string($concrete)) {
return $this->build($concrete);
}
if (is_callable($concrete)) {
$resolved = $concrete($this);
if (! is_object($resolved)) {
throw new Exception("Binding for {$abstract} did not resolve to an object.");
}
return $resolved;
}
throw new Exception("Unable to resolve {$abstract}.");
}
First we check if we have a binding for the requested class. If we don't, we just try to build the class itself. If the binding is a closure, we call it and pass the container to it, so the closure can resolve its own dependencies.
It calls the build() method, and this is where the Reflection API comes in. Using ReflectionClass we can inspect the constructor of a class and see which parameters it needs:
private function build(string $concrete): object
{
if (! class_exists($concrete)) {
throw new Exception("Class {$concrete} does not exist.");
}
$reflection = new ReflectionClass($concrete);
if (! $reflection->isInstantiable()) {
throw new Exception("Class {$concrete} is not instantiable.");
}
$constructor = $reflection->getConstructor();
if ($constructor === null) {
return new $concrete();
}
return $reflection->newInstanceArgs(
$this->resolveParameters($constructor->getParameters(), $concrete),
);
}
If there is no constructor, we can simply create the object. If there is one, we resolve each of its parameters and create the object with them:
private function resolveParameters(array $parameters, string $target): array
{
$dependencies = [];
foreach ($parameters as $parameter) {
$type = $parameter->getType();
if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) {
if ($parameter->isDefaultValueAvailable()) {
$dependencies[] = $parameter->getDefaultValue();
continue;
}
throw new Exception("Unable to resolve parameter \${$parameter->getName()} for {$target}.");
}
$dependencies[] = $this->make($type->getName());
}
return $dependencies;
}
For every parameter that is typed with a class, we call make() again. That class can have its own dependencies, so make() calls build(), build() calls make() and so on, until we get to classes that don't need anything. This is called autowiring -- the container figures out the whole dependency tree by itself, just by reading the type hints.
The container can't guess primitives. If the constructor needs string $host, there is no way of knowing which string we want, so we either use the default value or throw an exception. The same goes for union types (Mailer|Logger), since we don't know which one to pick.
bind() and interfaces
Autowiring works only for concrete classes.
public function handle(Mailer $mailer): void
An interface can't be instantiated, and the container has no way of knowing which implementation we want. There could be SmtpMailer, FakeMailer, SesMailer... So we have to tell it:
$container->bind(Mailer::class, FakeMailer::class);
bind() simply saves the instruction:
public function bind(string $abstract, string|callable $concrete): void
{
$this->bindings[$abstract] = $concrete;
}
Now when someone asks for Mailer, make() finds the binding and builds FakeMailer instead. If we forget to bind it, we want a clear message, rather than PHP's Cannot instantiate interface error, so we add a check at the top of build():
if (interface_exists($concrete)) {
throw new Exception("Cannot auto-resolve interface {$concrete}. Bind it to a concrete class first.");
}
Concrete classes are resolved automatically, while interfaces must be bound to some implementation, usually in a service provider, which is exactly what Laravel does. This makes it so easy to swap implementations in tests: you only change the binding, and the code that uses the interface stays the same.
singleton()
bind() creates a new object on every make() call. That is fine for a mailer, but some things should be created only once. A Redis connection is a perfect example: we don't want to open a new connection every time someone needs Redis.
public function singleton(string $abstract, string|callable $concrete): void
{
$this->bindings[$abstract] = function (self $container) use ($abstract, $concrete): object {
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
$resolved = is_string($concrete)
? $container->build($concrete)
: $concrete($container);
if (! is_object($resolved)) {
throw new Exception("Singleton binding for {$abstract} did not resolve to an object.");
}
return $this->instances[$abstract] = $resolved;
};
}
Singleton is just a binding that is wrapped in a closure. The first time it is called it creates the object and saves it in $instances. Every next time it returns the saved object.
$container->singleton(Mailer::class, FakeMailer::class);
$mailerA = $container->make(Mailer::class);
$mailerB = $container->make(Mailer::class);
$mailerA === $mailerB; // true
instance()
instance() is similar to singleton, but with one important difference. singleton() means "container, you create it once". instance() means "I already created it, container, store this exact object":
public function instance(string $abstract, object $instance): void
{
$this->instances[$abstract] = $instance;
}
// ...
$mailer = new FakeMailer();
$container->instance(Mailer::class, $mailer);
$container->make(Mailer::class) === $mailer; // true
That is why make() checks $instances before anything else:
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
So to sum up:
| Method | Who creates the object? | When is it created? | Reused? |
|---|---|---|---|
bind() |
Container | Every make() call |
No |
singleton() |
Container | First make() call |
Yes |
instance() |
You | Before registering | Yes |
In Hobby, singleton(Client::class, ...) means that Redis is created lazily, only when someone needs it. With instance(Client::class, $redis) Redis would be created immediately, with the config we manually passed.
call()
So far make() resolves constructor dependencies. But in Hobby, we already have the hobby object since the worker creates it from the queue payload. What we actually need is to call its handle() method and inject that method's dependencies, and that is what call() does:
public function call(object $object, string $method = 'handle'): mixed
{
$class = $object::class;
if (! method_exists($object, $method)) {
throw new Exception("Method {$class}::{$method} does not exist.");
}
$reflection = new ReflectionMethod($object, $method);
if (! $reflection->isPublic()) {
throw new Exception("Method {$class}::{$method} is not public.");
}
return $reflection->invokeArgs(
$object,
$this->resolveParameters($reflection->getParameters(), "{$class}::{$method}"),
);
}
It is pretty simple. It inspects the method using ReflectionMethod, resolves its parameters with the same resolveParameters() that make() uses, and calls the method with them. So this:
$container->call($hobby, 'handle');
conceptually becomes:
$mailer = $container->make(Mailer::class);
$hobby->handle($mailer);
So:
make()resolves constructor dependenciescall()resolves method dependencies
Why does that distinction matter for hobbies?
If you remember from the previous blog, when a hobby is dispatched, its constructor arguments are serialized and saved in Redis:
lrange queue:container-demo 0 -1
1) "{\"class\":\"demos\\\\container\\\\InjectedWelcomeEmailHobby\",\"args\":[\"injected@example.com\"],\"attempts\":0,\"queue\":\"container-demo\"}"
An email address can be saved in Redis, but a mailer can't. It can hold open connections, config and other objects, and none of that makes sense in a JSON payload that is going to be read by a different process, maybe hours later.
So the constructor should contain only the data, and services should come through handle():
#[OnQueue('container-demo')]
final readonly class InjectedWelcomeEmailHobby implements Hobby
{
public function __construct(
private string $email, // queued data
) {}
public function handle(Mailer $mailer): void
{
// runtime services
$mailer->sendWelcomeEmail($this->email);
}
}
The worker reconstructs the hobby from the saved data, exactly like before:
$hobby = new $class(...$args);
and then the container injects runtime services:
$this->container->call($hobby, 'handle');
This is the same split that Laravel jobs have. Data goes through the constructor and gets serialized, services go through handle() and get resolved when the job runs.
There is one small catch. Our Hobby interface had handle(): void in it, and handle(Mailer $mailer): void is not compatible with that signature and PHP would throw a fatal error. So Hobby became a marker interface, with no methods at all:
interface Hobby
{
}
Laravel solves this the same way: ShouldQueue doesn't define handle() either.
Wiring it all together
Now that we have a container, we don't have to manually create the hobbyist, dispatcher and executor in every demo. A small bootstrap file creates the container and registers what can't be autowired:
$container = new Container();
$container->instance(Container::class, $container);
$container->singleton(Client::class, static fn (Container $container): Client => new Client());
return $container;
My original plan was to register Dispatcher, Hobbyist and CooperativeExecutor here as well, but it turned out to be unnecessary. All of them only need Redis or the container, so autowiring handles them by itself. Only Redis is registered, because we want only one connection.
The first line is also instructive. The container registers itself as an instance. Hobbyist asks for Container in its constructor, and without this line the container would build a brand new, empty Container and inject that one, with none of our bindings. This is basically what Laravel's $app is.
So now the demo worker looks like this:
$container = require dirname(__DIR__, 2) . '/bootstrap/container.php';
$container->bind(Mailer::class, FakeMailer::class);
$hobbyist = $container->make(Hobbyist::class);
$hobbyist->run('container-demo');
make(Hobbyist::class) reads the constructor, sees Client, CooperativeExecutor and Container, resolves all of them, and gives us a ready hobbyist. In Concurrency in PHP with Fibers we added hobbies that run inside fibers, and those go through the container as well:
fiber: new Fiber(fn() => $this->container->call($hobby, 'handle')),
Testing
In one terminal we will run php demos/container/hobbyist.php and in the other php demos/container/dispatch.php, which dispatches both the manual and the injected hobby.
php demos/container/hobbyist.php
[2026-10-30 21:50:58] Welcome email sent to manual@example.com
[21:50:58] ✓ demos\container\ManualWelcomeEmailHobby succeeded (attempt 1/3)
[2026-10-30 21:50:58] Welcome email sent to injected@example.com
[21:50:58] ✓ demos\container\InjectedWelcomeEmailHobby succeeded (attempt 1/3)
Both hobbies did the same thing, but the second one never created the mailer. If we remove the bind() line from the hobbyist, the manual hobby still works, but the injected one fails, and because it fails inside the hobbyist, it goes through the normal retry flow:
[21:52:10] ↺ demos\container\InjectedWelcomeEmailHobby failed, retrying (attempt 1/3): Cannot auto-resolve interface demos\container\Mailer. Bind it to a concrete class first.
Circular dependencies
There is one problem with recursive make(). What happens if A needs B, and B needs A?
final readonly class DatabaseConnection
{
public function __construct(
public UserRepository $userRepository,
) {}
}
final readonly class UserRepository
{
public function __construct(
public DatabaseConnection $databaseConnection,
) {}
}
make() calls build(), build() calls make(), and so on, until PHP dies. To catch this, the container keeps a stack of classes it is currently resolving. If a class is requested while it is already on the stack, we have a circle:
if (in_array($abstract, $this->resolving, strict: true)) {
$dependencyChain = implode(' -> ', [...$this->resolving, $abstract]);
throw new Exception("Circular dependency detected: {$dependencyChain}.");
}
$this->resolving[] = $abstract;
try {
// resolve like before
} finally {
array_pop($this->resolving);
}
The class has to be removed from the stack whether resolving succeeded or failed, otherwise one failed make() would break every next one. Running php demos/container/circular-dependency.php gives us:
Circular dependency detected: demos\container\circularDependency\DatabaseConnection -> demos\container\circularDependency\UserRepository -> demos\container\circularDependency\DatabaseConnection.
which is a lot more helpful than a memory error.
What Laravel does beyond this
Laravel's container is built on the same ideas, but it does a lot more. Some of the things that are missing in our version:
- contextual binding: a different implementation of the same interface, depending on who is asking. For example,
AdminControllergetsEmailNotificationServiceandDeveloperControllergetsSlackNotificationService:
$this->app->when(AdminController::class)
->needs(NotificationService::class)
->give(EmailNotificationService::class);
- primitives: with
->needs('$apiKey')->giveConfig('mail.api_key')Laravel can inject a primitive value, which our container simply refuses to do - tagging: grouping several bindings under one name and injecting all of them at once
- method injection mixed with data: Laravel's
call()accepts extra parameters, which is how an event listener getshandle(OrderShipped $event, Notifier $notifier) - service providers: a structured place to register all the bindings, instead of a single bootstrap file. That is probably a good topic for another blog.
Conclusion
When I first started learning Laravel, the container looked like magic to me. Demystifying it by breaking it into a few arrays and a lot of Reflection logic feels really good. Code can be found on my GitHub page.