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.
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.
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): Order10 {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.
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 app13$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.
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): void12 {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): mixed22 {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 things14$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// Resolve23$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.
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 automatically11 return $this->build($abstract);12 }13 14 private function build(string $class): object15 {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, Logger6// 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 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.
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 time13 $this->app->singleton(CartService::class, function ($app) {14 return new CartService(15 $app->make(ProductRepository::class),16 $app->make(PricingService::class)17 );18 });19}
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 CheckoutController2{3 public function __construct(4 private PaymentGateway $gateway // Gets StripeGateway5 ) {}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.
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.
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 OrderController12{13 public function store(Request $request, OrderService $service)14 {15 // $service is automatically resolved16 }17}
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.
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 bindings12$reports = app()->tagged('reports');13foreach ($reports as $report) {14 $report->generate();15}
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.
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(): void23 {24 // After all providers registered, do setup that depends on other services25 }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.
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(): array14 {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.
Understanding when the container does its work helps debug issues:
register method runs, adding bindings.boot method runs.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 users7// 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.
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 UserRepositoryInterface10{11 public function find(int $id): ?User12 {13 return User::find($id);14 }15 16 public function save(User $user): void17 {18 $user->save();19 }20}21 22// Service provider23$this->app->bind(UserRepositoryInterface::class, EloquentUserRepository::class);24 25// Usage - type-hint the interface26class UserController27{28 public function __construct(29 private UserRepositoryInterface $users30 ) {}31 32 public function show(int $id): Response33 {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.
1class OrderService2{3 public function createOrder(): Order4 {5 $db = app(Database::class); // Don't do this!6 $mailer = app(Mailer::class); // Service location, not DI7 // ...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 OrderService2{3 public function __construct(4 private Database $db,5 private Mailer $mailer6 ) {}7}
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 $notifications13) {}
This often indicates a class with too many responsibilities. Consider breaking it into smaller, focused classes.
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 params2$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:
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 everywhere10 $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.
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:
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.
Written by
Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.
Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.