Laravel Magazine

Dependency Injection and the Container in PHP and Laravel

Eric Van Johnson · Laravel Php
Dependency Injection and the Container in PHP and Laravel

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:

class OrderService
{
    public function createOrder(array $items): Order
    {
        $db = new MySQLDatabase('localhost', 'shop', 'root', 'password');
        $mailer = new SmtpMailer('smtp.gmail.com', 587, 'user', 'pass');
        $logger = new FileLogger('/var/log/orders.log');
        
        $logger->info('Creating order with ' . count($items) . ' items');
        
        $order = new Order($items);
        $db->insert('orders', $order->toArray());
        
        $mailer->send($order->customerEmail, 'Order Confirmation', '...');
        
        return $order;
    }
}

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:

class OrderService
{
    public function __construct(
        private Database $db,
        private Mailer $mailer,
        private Logger $logger,
    ) {}
    
    public function createOrder(array $items): Order
    {
        $this->logger->info('Creating order with ' . count($items) . ' items');
        
        $order = new Order($items);
        $this->db->insert('orders', $order->toArray());
        
        $this->mailer->send($order->customerEmail, 'Order Confirmation', '...');
        
        return $order;
    }
}

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:

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

$orderService = new OrderService($db, $mailer, $logger);

Testing becomes easy:

public function testCreateOrderLogsItemCount(): void
{
    $db = $this->createMock(Database::class);
    $mailer = $this->createMock(Mailer::class);
    $logger = $this->createMock(Logger::class);
    
    $logger->expects($this->once())
        ->method('info')
        ->with($this->stringContains('3 items'));
    
    $service = new OrderService($db, $mailer, $logger);
    $service->createOrder(['item1', 'item2', 'item3']);
}

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:

// Imagine doing this for every request
$config = new Config();
$db = new MySQLDatabase(
    $config->get('db.host'),
    $config->get('db.name'),
    $config->get('db.user'),
    $config->get('db.pass')
);
$mailer = new SmtpMailer(
    $config->get('mail.host'),
    $config->get('mail.port'),
    $config->get('mail.user'),
    $config->get('mail.pass')
);
$logger = new FileLogger($config->get('log.path'));
$userRepository = new UserRepository($db);
$orderRepository = new OrderRepository($db);
$orderService = new OrderService($db, $mailer, $logger);
$userService = new UserService($userRepository, $mailer, $logger);
// ... 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.

// Register how to build things
$container->bind(Database::class, function ($container) {
    $config = $container->get(Config::class);
    return new MySQLDatabase(
        $config->get('db.host'),
        $config->get('db.name'),
        $config->get('db.user'),
        $config->get('db.pass')
    );
});

// Later, anywhere in your app
$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:

class Container
{
    private array $bindings = [];
    private array $instances = [];
    
    public function bind(string $abstract, callable $factory): void
    {
        $this->bindings[$abstract] = $factory;
    }
    
    public function singleton(string $abstract, callable $factory): void
    {
        $this->bind($abstract, function ($container) use ($abstract, $factory) {
            if (!isset($this->instances[$abstract])) {
                $this->instances[$abstract] = $factory($container);
            }
            return $this->instances[$abstract];
        });
    }
    
    public function get(string $abstract): mixed
    {
        if (!isset($this->bindings[$abstract])) {
            throw new Exception("No binding found for {$abstract}");
        }
        
        return $this->bindings[$abstract]($this);
    }
}

Usage:

$container = new Container();

// Register a singleton (same instance every time)
$container->singleton(Database::class, function ($c) {
    return new MySQLDatabase('localhost', 'shop', 'root', 'password');
});

// Register a factory (new instance every time)
$container->bind(Logger::class, function ($c) {
    return new FileLogger('/var/log/app.log');
});

// Register something that depends on other things
$container->bind(OrderService::class, function ($c) {
    return new OrderService(
        $c->get(Database::class),
        $c->get(Mailer::class),
        $c->get(Logger::class)
    );
});

// Resolve
$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:

class SmartContainer extends Container
{
    public function get(string $abstract): mixed
    {
        // If we have an explicit binding, use it
        if (isset($this->bindings[$abstract])) {
            return $this->bindings[$abstract]($this);
        }
        
        // Otherwise, try to build it automatically
        return $this->build($abstract);
    }
    
    private function build(string $class): object
    {
        $reflection = new ReflectionClass($class);
        
        if (!$reflection->isInstantiable()) {
            throw new Exception("Cannot instantiate {$class}");
        }
        
        $constructor = $reflection->getConstructor();
        
        if ($constructor === null) {
            return new $class();
        }
        
        $dependencies = [];
        
        foreach ($constructor->getParameters() as $param) {
            $type = $param->getType();
            
            if ($type === null || $type->isBuiltin()) {
                if ($param->isDefaultValueAvailable()) {
                    $dependencies[] = $param->getDefaultValue();
                } else {
                    throw new Exception("Cannot resolve parameter {$param->getName()}");
                }
            } else {
                $dependencies[] = $this->get($type->getName());
            }
        }
        
        return $reflection->newInstanceArgs($dependencies);
    }
}

Now this works without any explicit bindings:

$container = new SmartContainer();

// No bindings registered!
$orderService = $container->get(OrderService::class);
// Container sees OrderService needs Database, Mailer, Logger
// It resolves each of those (which may have their own dependencies)
// 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

// In a service provider
public function register(): void
{
    // Simple binding - new instance each time
    $this->app->bind(PaymentGateway::class, function ($app) {
        return new StripeGateway(
            config('services.stripe.key'),
            config('services.stripe.secret')
        );
    });
    
    // Singleton - same instance every time
    $this->app->singleton(CartService::class, function ($app) {
        return new CartService(
            $app->make(ProductRepository::class),
            $app->make(PricingService::class)
        );
    });
}

Interface Binding

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

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

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

class CheckoutController
{
    public function __construct(
        private PaymentGateway $gateway  // Gets StripeGateway
    ) {}
}

To switch payment providers, change one line:

$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:

$this->app->when(PhotoController::class)
    ->needs(Filesystem::class)
    ->give(function () {
        return new S3Filesystem(config('filesystems.s3'));
    });

$this->app->when(VideoController::class)
    ->needs(Filesystem::class)
    ->give(function () {
        return new LocalFilesystem(storage_path('videos'));
    });

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:

// Using app() helper
$service = app(OrderService::class);

// Using make()
$service = app()->make(OrderService::class);

// Using resolve() helper
$service = resolve(OrderService::class);

// Type-hinting in controllers, jobs, commands (auto-injected)
class OrderController
{
    public function store(Request $request, OrderService $service)
    {
        // $service is automatically resolved
    }
}

Method Injection

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

class ReportController
{
    public function generate(
        Request $request,
        ReportGenerator $generator,  // Resolved from container
        PdfExporter $exporter         // Resolved from container
    ) {
        $report = $generator->generate($request->filters);
        return $exporter->export($report);
    }
}

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

Tagging

Group related bindings together:

$this->app->bind(CpuReport::class);
$this->app->bind(MemoryReport::class);
$this->app->bind(DiskReport::class);

$this->app->tag([
    CpuReport::class,
    MemoryReport::class,
    DiskReport::class,
], 'reports');

// Later, resolve all tagged bindings
$reports = app()->tagged('reports');
foreach ($reports as $report) {
    $report->generate();
}

Extending Bindings

Modify how something is resolved without replacing the entire binding:

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

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:

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(PaymentGateway::class, function ($app) {
            return match (config('payment.driver')) {
                'stripe' => new StripeGateway(config('payment.stripe')),
                'braintree' => new BraintreeGateway(config('payment.braintree')),
                'paypal' => new PayPalGateway(config('payment.paypal')),
                default => throw new InvalidArgumentException('Unknown payment driver'),
            };
        });
        
        $this->app->bind(RefundService::class, function ($app) {
            return new RefundService(
                $app->make(PaymentGateway::class),
                $app->make(OrderRepository::class)
            );
        });
    }
    
    public function boot(): void
    {
        // After all providers registered, do setup that depends on other services
    }
}

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:

class HeavyServiceProvider extends ServiceProvider
{
    public $defer = true;
    
    public function register(): void
    {
        $this->app->singleton(ExpensiveService::class, function ($app) {
            // This closure only runs when ExpensiveService is requested
            return new ExpensiveService(/* complex setup */);
        });
    }
    
    public function provides(): array
    {
        return [ExpensiveService::class];
    }
}

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.

// This singleton holds user data - dangerous with Octane!
$this->app->singleton(CartService::class, function ($app) {
    return new CartService();
});

// The same CartService instance serves multiple users
// Their carts would get mixed up

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

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

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

Real-World Pattern: Repository Binding

A common pattern binds repository interfaces to implementations:

// Interfaces
interface UserRepositoryInterface
{
    public function find(int $id): ?User;
    public function save(User $user): void;
}

// Implementation
class EloquentUserRepository implements UserRepositoryInterface
{
    public function find(int $id): ?User
    {
        return User::find($id);
    }
    
    public function save(User $user): void
    {
        $user->save();
    }
}

// Service provider
$this->app->bind(UserRepositoryInterface::class, EloquentUserRepository::class);

// Usage - type-hint the interface
class UserController
{
    public function __construct(
        private UserRepositoryInterface $users
    ) {}
    
    public function show(int $id): Response
    {
        $user = $this->users->find($id);
        // ...
    }
}

In tests, you can bind a different implementation:

public function testShowUser(): void
{
    $this->app->bind(UserRepositoryInterface::class, function () {
        $mock = $this->createMock(UserRepositoryInterface::class);
        $mock->method('find')->willReturn(new User(['name' => 'Test']));
        return $mock;
    });
    
    $response = $this->get('/users/1');
    $response->assertSee('Test');
}

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

class OrderService
{
    public function createOrder(): Order
    {
        $db = app(Database::class);  // Don't do this!
        $mailer = app(Mailer::class);  // Service location, not DI
        // ...
    }
}

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:

class OrderService
{
    public function __construct(
        private Database $db,
        private Mailer $mailer
    ) {}
}

Mistake: Over-Injecting

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

// Too many dependencies - this class does too much
public function __construct(
    private Database $db,
    private Mailer $mailer,
    private Logger $logger,
    private Cache $cache,
    private Queue $queue,
    private EventDispatcher $events,
    private Validator $validator,
    private FileStorage $storage,
    private PdfGenerator $pdf,
    private NotificationService $notifications
) {}

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:

// Unnecessary if OrderService has type-hinted constructor params
$this->app->bind(OrderService::class, function ($app) {
    return new OrderService(
        $app->make(Database::class),
        $app->make(Mailer::class)
    );
});

// 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:

public function testOrderCreation(): void
{
    // Replace the real mailer with a fake
    Mail::fake();
    
    // Or replace any service
    $this->app->instance(PaymentGateway::class, new FakePaymentGateway());
    
    // Now test - the fake gateway is injected everywhere
    $response = $this->post('/orders', ['items' => [...]]);
    
    $response->assertSuccessful();
}

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.

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.