December 15th, 2025

Dependency Injection and the Container in PHP and Laravel

Dependency Injection and the Container in PHP and Laravel
Sponsored by
Table of Contents

It took me months to understand that dependency injection isn't really about the mechanics of passing objects around. It's about inverting control about deciding that your classes shouldn't be responsible for finding or creating the things they need. Once that clicked, the container stopped being a mysterious black box and became an obvious, natural tool.

This article builds dependency injection from first principles, explains what containers actually do, and shows how Laravel's container works in practice. By the end, you'll have a mental model that makes the whole system intuitive.

The Problem: Hard-Coded Dependencies

Let's start with code that doesn't use dependency injection:

1class OrderService
2{
3 public function createOrder(array $items): Order
4 {
5 $db = new MySQLDatabase('localhost', 'shop', 'root', 'password');
6 $mailer = new SmtpMailer('smtp.gmail.com', 587, 'user', 'pass');
7 $logger = new FileLogger('/var/log/orders.log');
8 
9 $logger->info('Creating order with ' . count($items) . ' items');
10 
11 $order = new Order($items);
12 $db->insert('orders', $order->toArray());
13 
14 $mailer->send($order->customerEmail, 'Order Confirmation', '...');
15 
16 return $order;
17 }
18}

This works, but it has serious problems:

Testing is painful. To test createOrder(), you need a real MySQL database, a real SMTP server, and a real log file. You can't isolate the order logic from the infrastructure.

Configuration is scattered. Database credentials are buried inside a method. If you need to change the database host, you're hunting through code to find where it's instantiated.

Reuse is impossible. What if another service needs the same database connection? It creates its own, wasting resources and duplicating configuration.

Changing implementations is hard. Want to switch from MySQL to PostgreSQL? From SMTP to a mail API? You're editing the OrderService class even though the order logic hasn't changed.

The Solution: Inject Dependencies

Dependency injection means giving a class what it needs rather than letting it create or find those things itself:

1class OrderService
2{
3 public function __construct(
4 private Database $db,
5 private Mailer $mailer,
6 private Logger $logger,
7 ) {}
8 
9 public function createOrder(array $items): Order
10 {
11 $this->logger->info('Creating order with ' . count($items) . ' items');
12 
13 $order = new Order($items);
14 $this->db->insert('orders', $order->toArray());
15 
16 $this->mailer->send($order->customerEmail, 'Order Confirmation', '...');
17 
18 return $order;
19 }
20}

Now OrderService doesn't know or care how the database connects, how the mailer sends emails, or where logs go. It receives objects that implement the interfaces it needs and uses them.

Creating an OrderService now looks like this:

1$db = new MySQLDatabase('localhost', 'shop', 'root', 'password');
2$mailer = new SmtpMailer('smtp.gmail.com', 587, 'user', 'pass');
3$logger = new FileLogger('/var/log/orders.log');
4 
5$orderService = new OrderService($db, $mailer, $logger);

Testing becomes easy:

1public function testCreateOrderLogsItemCount(): void
2{
3 $db = $this->createMock(Database::class);
4 $mailer = $this->createMock(Mailer::class);
5 $logger = $this->createMock(Logger::class);
6 
7 $logger->expects($this->once())
8 ->method('info')
9 ->with($this->stringContains('3 items'));
10 
11 $service = new OrderService($db, $mailer, $logger);
12 $service->createOrder(['item1', 'item2', 'item3']);
13}

You can pass mock objects that verify behavior without touching real infrastructure.

Configuration is centralized. Database credentials live in one place, and everything that needs a database receives the same configured instance.

Implementations are swappable. Switch from MySQLDatabase to PostgreSQLDatabase in one place, and every service using Database gets the new implementation.

This is dependency injection. The "injection" is the act of passing dependencies in (usually through the constructor). The class doesn't reach out to get what it needs, dependencies are pushed into it.

The Mental Model: A Warehouse for Your Application

Here's where people get confused. Dependency injection is simple, but manually wiring everything together gets tedious:

1// Imagine doing this for every request
2$config = new Config();
3$db = new MySQLDatabase(
4 $config->get('db.host'),
5 $config->get('db.name'),
6 $config->get('db.user'),
7 $config->get('db.pass')
8);
9$mailer = new SmtpMailer(
10 $config->get('mail.host'),
11 $config->get('mail.port'),
12 $config->get('mail.user'),
13 $config->get('mail.pass')
14);
15$logger = new FileLogger($config->get('log.path'));
16$userRepository = new UserRepository($db);
17$orderRepository = new OrderRepository($db);
18$orderService = new OrderService($db, $mailer, $logger);
19$userService = new UserService($userRepository, $mailer, $logger);
20// ... and on and on

This is where the container comes in. Think of it as a warehouse with smart workers.

Imagine a massive warehouse that stores all the tools and materials your business needs. When someone needs a forklift, they don't build one from scratch, they go to the warehouse and ask for a forklift. The warehouse either hands them one that's already available or builds one according to the instructions on file.

The container is that warehouse. You tell it: "When someone asks for a Database, here's how to build one" or "When someone asks for a Mailer, give them this specific instance." Then, throughout your application, code just asks the container for what it needs.

1// Register how to build things
2$container->bind(Database::class, function ($container) {
3 $config = $container->get(Config::class);
4 return new MySQLDatabase(
5 $config->get('db.host'),
6 $config->get('db.name'),
7 $config->get('db.user'),
8 $config->get('db.pass')
9 );
10});
11 
12// Later, anywhere in your app
13$db = $container->get(Database::class); // Container builds it for you

Another mental model: state management for the backend.

If you've used Redux, Vuex, or similar state management in frontend applications, you understand the idea of a central store that holds application state. Components don't manage their own data, they pull from the store and dispatch actions to modify it.

The container is similar but for objects instead of data. Your classes don't create their own dependencies, they receive them from the container. The container is the single source of truth for "how do we build and access the objects in this application."

One more analogy: a recipe book with a personal chef.

The container is like having a recipe book combined with a chef who reads it. You write down recipes: "To make OrderService, you need Database, Mailer, and Logger. To make Database, you need Config and these specific parameters." When you're hungry for an OrderService, you don't cook, you ask the chef, who follows the recipes, handles all the sub-recipes automatically, and hands you the finished dish.

Building a Simple Container

Understanding how containers work demystifies them. Let's build one:

1class Container
2{
3 private array $bindings = [];
4 private array $instances = [];
5 
6 public function bind(string $abstract, callable $factory): void
7 {
8 $this->bindings[$abstract] = $factory;
9 }
10 
11 public function singleton(string $abstract, callable $factory): void
12 {
13 $this->bind($abstract, function ($container) use ($abstract, $factory) {
14 if (!isset($this->instances[$abstract])) {
15 $this->instances[$abstract] = $factory($container);
16 }
17 return $this->instances[$abstract];
18 });
19 }
20 
21 public function get(string $abstract): mixed
22 {
23 if (!isset($this->bindings[$abstract])) {
24 throw new Exception("No binding found for {$abstract}");
25 }
26 
27 return $this->bindings[$abstract]($this);
28 }
29}

Usage:

1$container = new Container();
2 
3// Register a singleton (same instance every time)
4$container->singleton(Database::class, function ($c) {
5 return new MySQLDatabase('localhost', 'shop', 'root', 'password');
6});
7 
8// Register a factory (new instance every time)
9$container->bind(Logger::class, function ($c) {
10 return new FileLogger('/var/log/app.log');
11});
12 
13// Register something that depends on other things
14$container->bind(OrderService::class, function ($c) {
15 return new OrderService(
16 $c->get(Database::class),
17 $c->get(Mailer::class),
18 $c->get(Logger::class)
19 );
20});
21 
22// Resolve
23$orderService = $container->get(OrderService::class);

That's the core idea. Real containers add features like automatic resolution (figuring out dependencies without explicit bindings), interface binding, contextual binding, and more. But underneath, they're all doing this: storing instructions for building objects and executing those instructions when asked.

Automatic Resolution

Modern containers can figure out dependencies automatically using reflection. If a class has type-hinted constructor parameters, the container can read those hints and resolve each dependency:

1class SmartContainer extends Container
2{
3 public function get(string $abstract): mixed
4 {
5 // If we have an explicit binding, use it
6 if (isset($this->bindings[$abstract])) {
7 return $this->bindings[$abstract]($this);
8 }
9 
10 // Otherwise, try to build it automatically
11 return $this->build($abstract);
12 }
13 
14 private function build(string $class): object
15 {
16 $reflection = new ReflectionClass($class);
17 
18 if (!$reflection->isInstantiable()) {
19 throw new Exception("Cannot instantiate {$class}");
20 }
21 
22 $constructor = $reflection->getConstructor();
23 
24 if ($constructor === null) {
25 return new $class();
26 }
27 
28 $dependencies = [];
29 
30 foreach ($constructor->getParameters() as $param) {
31 $type = $param->getType();
32 
33 if ($type === null || $type->isBuiltin()) {
34 if ($param->isDefaultValueAvailable()) {
35 $dependencies[] = $param->getDefaultValue();
36 } else {
37 throw new Exception("Cannot resolve parameter {$param->getName()}");
38 }
39 } else {
40 $dependencies[] = $this->get($type->getName());
41 }
42 }
43 
44 return $reflection->newInstanceArgs($dependencies);
45 }
46}

Now this works without any explicit bindings:

1$container = new SmartContainer();
2 
3// No bindings registered!
4$orderService = $container->get(OrderService::class);
5// Container sees OrderService needs Database, Mailer, Logger
6// It resolves each of those (which may have their own dependencies)
7// Then constructs OrderService with all dependencies

This is called auto-wiring. Laravel's container does this extensively.

Laravel's Container

Laravel's service container is one of the most powerful and well-designed in the PHP ecosystem. It's available everywhere as app() or through the Illuminate\Container\Container class.

Basic Binding

1// In a service provider
2public function register(): void
3{
4 // Simple binding - new instance each time
5 $this->app->bind(PaymentGateway::class, function ($app) {
6 return new StripeGateway(
7 config('services.stripe.key'),
8 config('services.stripe.secret')
9 );
10 });
11 
12 // Singleton - same instance every time
13 $this->app->singleton(CartService::class, function ($app) {
14 return new CartService(
15 $app->make(ProductRepository::class),
16 $app->make(PricingService::class)
17 );
18 });
19}

Interface Binding

One of the most powerful patterns, bind an interface to an implementation:

1$this->app->bind(PaymentGateway::class, StripeGateway::class);

Now any class that type-hints PaymentGateway receives a StripeGateway:

1class CheckoutController
2{
3 public function __construct(
4 private PaymentGateway $gateway // Gets StripeGateway
5 ) {}
6}

To switch payment providers, change one line:

1$this->app->bind(PaymentGateway::class, BraintreeGateway::class);

Every class using PaymentGateway now gets BraintreeGateway. No other code changes required.

Contextual Binding

Sometimes different classes need different implementations of the same interface:

1$this->app->when(PhotoController::class)
2 ->needs(Filesystem::class)
3 ->give(function () {
4 return new S3Filesystem(config('filesystems.s3'));
5 });
6 
7$this->app->when(VideoController::class)
8 ->needs(Filesystem::class)
9 ->give(function () {
10 return new LocalFilesystem(storage_path('videos'));
11 });

PhotoController gets S3 storage; VideoController gets local storage. Same interface, different implementations based on context.

Resolving from the Container

Several ways to get things from Laravel's container:

1// Using app() helper
2$service = app(OrderService::class);
3 
4// Using make()
5$service = app()->make(OrderService::class);
6 
7// Using resolve() helper
8$service = resolve(OrderService::class);
9 
10// Type-hinting in controllers, jobs, commands (auto-injected)
11class OrderController
12{
13 public function store(Request $request, OrderService $service)
14 {
15 // $service is automatically resolved
16 }
17}

Method Injection

Laravel can inject dependencies into specific methods, not just constructors:

1class ReportController
2{
3 public function generate(
4 Request $request,
5 ReportGenerator $generator, // Resolved from container
6 PdfExporter $exporter // Resolved from container
7 ) {
8 $report = $generator->generate($request->filters);
9 return $exporter->export($report);
10 }
11}

The container inspects the method signature and resolves each type-hinted parameter.

Tagging

Group related bindings together:

1$this->app->bind(CpuReport::class);
2$this->app->bind(MemoryReport::class);
3$this->app->bind(DiskReport::class);
4 
5$this->app->tag([
6 CpuReport::class,
7 MemoryReport::class,
8 DiskReport::class,
9], 'reports');
10 
11// Later, resolve all tagged bindings
12$reports = app()->tagged('reports');
13foreach ($reports as $report) {
14 $report->generate();
15}

Extending Bindings

Modify how something is resolved without replacing the entire binding:

1$this->app->extend(Logger::class, function ($logger, $app) {
2 return new LoggerWithMetrics($logger, $app->make(MetricsCollector::class));
3});

Every time Logger is resolved, it's wrapped in LoggerWithMetrics. Useful for adding cross-cutting concerns like monitoring or caching.

Service Providers: Organizing Bindings

Laravel uses service providers to organize container bindings. Each provider is responsible for registering related services:

1class PaymentServiceProvider extends ServiceProvider
2{
3 public function register(): void
4 {
5 $this->app->singleton(PaymentGateway::class, function ($app) {
6 return match (config('payment.driver')) {
7 'stripe' => new StripeGateway(config('payment.stripe')),
8 'braintree' => new BraintreeGateway(config('payment.braintree')),
9 'paypal' => new PayPalGateway(config('payment.paypal')),
10 default => throw new InvalidArgumentException('Unknown payment driver'),
11 };
12 });
13 
14 $this->app->bind(RefundService::class, function ($app) {
15 return new RefundService(
16 $app->make(PaymentGateway::class),
17 $app->make(OrderRepository::class)
18 );
19 });
20 }
21 
22 public function boot(): void
23 {
24 // After all providers registered, do setup that depends on other services
25 }
26}

The register method is for binding into the container. The boot method runs after all providers are registered, so you can safely resolve other services.

Deferred Providers

For performance, you can defer provider registration until the bindings are actually needed:

1class HeavyServiceProvider extends ServiceProvider
2{
3 public $defer = true;
4 
5 public function register(): void
6 {
7 $this->app->singleton(ExpensiveService::class, function ($app) {
8 // This closure only runs when ExpensiveService is requested
9 return new ExpensiveService(/* complex setup */);
10 });
11 }
12 
13 public function provides(): array
14 {
15 return [ExpensiveService::class];
16 }
17}

Deferred providers don't run their register method on every request, only when something they provide is actually resolved. This speeds up requests that don't need those services.

The Request Lifecycle and the Container

Understanding when the container does its work helps debug issues:

  1. Application bootstrap: Laravel creates the container and registers core bindings.
  2. Service provider registration: Each provider's register method runs, adding bindings.
  3. Service provider boot: Each provider's boot method runs.
  4. Request handling: As your code runs, dependencies are resolved from the container.
  5. Request termination: Cleanup happens, singletons persist for the next request (in long-running processes like Octane).

In a traditional PHP-FPM setup, the container is rebuilt on every request. Singletons are "single" for one request, not forever. With Laravel Octane or similar, the application persists between requests, so singletons truly persist, which can cause issues if they hold request-specific state.

1// This singleton holds user data - dangerous with Octane!
2$this->app->singleton(CartService::class, function ($app) {
3 return new CartService();
4});
5 
6// The same CartService instance serves multiple users
7// Their carts would get mixed up

With Octane, use scoped bindings for request-specific data:

1$this->app->scoped(CartService::class, function ($app) {
2 return new CartService();
3});

Scoped bindings are singletons within a request but reset between requests.

Real-World Pattern: Repository Binding

A common pattern binds repository interfaces to implementations:

1// Interfaces
2interface UserRepositoryInterface
3{
4 public function find(int $id): ?User;
5 public function save(User $user): void;
6}
7 
8// Implementation
9class EloquentUserRepository implements UserRepositoryInterface
10{
11 public function find(int $id): ?User
12 {
13 return User::find($id);
14 }
15 
16 public function save(User $user): void
17 {
18 $user->save();
19 }
20}
21 
22// Service provider
23$this->app->bind(UserRepositoryInterface::class, EloquentUserRepository::class);
24 
25// Usage - type-hint the interface
26class UserController
27{
28 public function __construct(
29 private UserRepositoryInterface $users
30 ) {}
31 
32 public function show(int $id): Response
33 {
34 $user = $this->users->find($id);
35 // ...
36 }
37}

In tests, you can bind a different implementation:

1public function testShowUser(): void
2{
3 $this->app->bind(UserRepositoryInterface::class, function () {
4 $mock = $this->createMock(UserRepositoryInterface::class);
5 $mock->method('find')->willReturn(new User(['name' => 'Test']));
6 return $mock;
7 });
8 
9 $response = $this->get('/users/1');
10 $response->assertSee('Test');
11}

The controller doesn't change, it still type-hints the interface. Only the binding changes.

Common Mistakes and Misconceptions

Mistake: Resolving from the Container Everywhere

1class OrderService
2{
3 public function createOrder(): Order
4 {
5 $db = app(Database::class); // Don't do this!
6 $mailer = app(Mailer::class); // Service location, not DI
7 // ...
8 }
9}

This is "service location," not dependency injection. It hides dependencies (you can't see what OrderService needs without reading the implementation) and makes testing harder.

Instead, inject through the constructor:

1class OrderService
2{
3 public function __construct(
4 private Database $db,
5 private Mailer $mailer
6 ) {}
7}

Mistake: Over-Injecting

If a class has ten constructor parameters, the design might need reconsideration:

1// Too many dependencies - this class does too much
2public function __construct(
3 private Database $db,
4 private Mailer $mailer,
5 private Logger $logger,
6 private Cache $cache,
7 private Queue $queue,
8 private EventDispatcher $events,
9 private Validator $validator,
10 private FileStorage $storage,
11 private PdfGenerator $pdf,
12 private NotificationService $notifications
13) {}

This often indicates a class with too many responsibilities. Consider breaking it into smaller, focused classes.

Mistake: Binding Everything Explicitly

Laravel's auto-wiring handles most cases. You don't need explicit bindings for concrete classes with type-hinted dependencies:

1// Unnecessary if OrderService has type-hinted constructor params
2$this->app->bind(OrderService::class, function ($app) {
3 return new OrderService(
4 $app->make(Database::class),
5 $app->make(Mailer::class)
6 );
7});
8 
9// Laravel figures this out automatically - no binding needed

Only bind explicitly when you need to:

  • Bind interfaces to implementations
  • Configure primitive parameters (API keys, config values)
  • Use singletons
  • Apply contextual or conditional logic

Testing with the Container

Laravel's container shines in testing. Override bindings for individual tests:

1public function testOrderCreation(): void
2{
3 // Replace the real mailer with a fake
4 Mail::fake();
5 
6 // Or replace any service
7 $this->app->instance(PaymentGateway::class, new FakePaymentGateway());
8 
9 // Now test - the fake gateway is injected everywhere
10 $response = $this->post('/orders', ['items' => [...]]);
11 
12 $response->assertSuccessful();
13}

The instance method binds a specific object instance, useful for pre-configured mocks or fakes.

Summary

Dependency injection is about receiving dependencies rather than creating them. It makes code testable, configurable, and flexible.

The container automates dependency injection at scale. Instead of manually wiring objects together, you teach the container how to build things, and it handles the rest. Think of it as a warehouse with recipes, ask for something, and it's built according to instructions.

Laravel's container adds powerful features: auto-wiring, interface binding, contextual binding, service providers, and deep framework integration. Type-hint what you need, and Laravel provides it.

The key insights:

  • Inject dependencies through constructors, not by reaching into the container
  • Bind interfaces, not just concrete classes
  • Let auto-wiring handle simple cases
  • Use service providers to organize related bindings
  • The container is a tool for building objects, not a global service locator

Once this mental model clicks, the container transforms from mysterious framework magic into an obvious, powerful tool. You'll wonder how you ever built applications without it.

Marian Pop

Written by

Marian Pop

Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.

Comments

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.