← Back to Dashboard

Dependency Injection / IoC

Laravel: Service Container, automatic constructor injection in Controllers and Services

Dependency Injection / Inversion of Control

The Real-World Analogy

Imagine you're building a payment kiosk that needs to print receipts. The kiosk has a receipt printer built into it -- soldered right onto the motherboard. When that printer breaks, you can't swap it out. You have to tear apart the entire kiosk, desolder the old one, and solder in a new one. If a client wants thermal printing instead of dot-matrix? Same story: rip and replace.

Now imagine a different design: the kiosk has a standard USB port. Any printer that speaks USB can plug in. Thermal printer, dot-matrix, even a virtual printer that saves to PDF. The kiosk doesn't care which printer is attached -- it only cares that the attached device can print.

That USB port is Dependency Injection. The kiosk doesn't create its own printer. Instead, the printer is provided from outside. The kiosk depends on a contract ("can you print?"), not a specific piece of hardware.

The Anti-Pattern: Hardcoded Dependencies

Here's code you'll find in legacy applications everywhere. A NotificationService that creates its own mailer:

class NotificationService {
    private SmtpMailer $mailer;

    public function __construct() {
        // Hardcoded dependency -- created internally
        $this->mailer = new SmtpMailer('smtp.company.com', 587, 'user', 'pass');
    }

    public function notify(string $to, string $message): bool {
        return $this->mailer->send($to, $message);
    }
}

Why this is a problem:

  1. Untestable. Every time you instantiate NotificationService, it creates a real SmtpMailer. In your unit tests, you're sending actual emails (or the tests fail because the SMTP server is unreachable).
  2. Rigid. Want to switch to a queue-based mailer? An SMS gateway? You have to modify NotificationService itself, even though notification logic hasn't changed.
  3. Hidden dependencies. Looking at the constructor signature __construct(), you have no idea this class needs SMTP credentials. The dependency is invisible.
  4. Violates the Open/Closed Principle. The class is not open for extension without modification.

The Fix: Inject the Dependency

Define a contract (interface), then pass the implementation from outside:

interface MailerInterface {
    public function send(string $to, string $body): bool;
}

class SmtpMailer implements MailerInterface {
    public function send(string $to, string $body): bool {
        // Real SMTP sending logic
        echo "SMTP: Sending to {$to}\n";
        return true;
    }
}

class NotificationService {
    public function __construct(
        private MailerInterface $mailer  // Injected, not created
    ) {}

    public function notify(string $to, string $message): bool {
        return $this->mailer->send($to, $message);
    }
}

// The caller decides which implementation to use
$service = new NotificationService(new SmtpMailer());
$service->notify('dev@company.com', 'Deployment complete');

Now NotificationService depends on an abstraction (MailerInterface), not a concrete class. You can pass in a SmtpMailer, a LogMailer, or a FakeMailer for testing -- NotificationService doesn't know or care.

Types of Dependency Injection

Constructor Injection (Preferred)

Dependencies are passed through the constructor. This is the most common and recommended form because it makes dependencies explicit and ensures the object is always in a valid state:

class PaymentProcessor {
    public function __construct(
        private GatewayInterface $gateway,
        private LoggerInterface $logger,
    ) {}
}

Method Injection

Dependencies are passed to a specific method rather than the constructor. Use this when the dependency is only needed for one operation:

class ReportGenerator {
    public function generate(FormatterInterface $formatter): string {
        $data = $this->collectData();
        return $formatter->format($data);
    }
}

Setter Injection (Use Sparingly)

Dependencies are set after construction via a setter method. This makes the dependency optional, which can lead to null reference errors if the setter is never called:

class Dashboard {
    private ?CacheInterface $cache = null;

    public function setCache(CacheInterface $cache): void {
        $this->cache = $cache;
    }
}

Setter injection is the weakest form. Prefer constructor injection unless you have a genuine reason for an optional dependency.

Why DI Matters for Testing

Without DI, testing requires either hitting real external services or using hacky workarounds like monkey-patching. With DI, you create a test double that implements the same interface:

class FakeMailer implements MailerInterface {
    public array $sentMessages = [];

    public function send(string $to, string $body): bool {
        $this->sentMessages[] = ['to' => $to, 'body' => $body];
        return true;
    }
}

// In your test:
$fakeMailer = new FakeMailer();
$service = new NotificationService($fakeMailer);
$service->notify('test@example.com', 'Hello');

assert(count($fakeMailer->sentMessages) === 1);
assert($fakeMailer->sentMessages[0]['to'] === 'test@example.com');

No real emails sent. No SMTP server needed. The test runs in milliseconds and verifies the behavior you actually care about: that NotificationService calls send() with the right arguments.

Inversion of Control

"Inversion of Control" (IoC) is the broader principle that DI implements. Normally, a class controls its own dependencies -- it creates what it needs. IoC flips (inverts) that: something outside the class provides the dependencies. The class gives up control over which implementation it uses.

In frameworks like Laravel, the IoC container (also called the "service container") automates this process. You register bindings ("when someone asks for MailerInterface, give them SmtpMailer"), and the container resolves dependencies automatically when constructing objects.


Key takeaway: If a class creates its own collaborators with new, it's tightly coupled. If it receives its collaborators through its constructor, it's loosely coupled. Loose coupling makes code testable, flexible, and easier to understand.

What You Learned

  • Dependency Injection means passing dependencies in from outside rather than creating them internally
  • Constructor injection is the preferred form — it makes dependencies explicit and required
  • DI enables testability by allowing fake/mock dependencies to be swapped in
  • The pattern follows the Dependency Inversion Principle: depend on abstractions, not concretions

Dependency Injection in Laravel

Laravel's Service Container is the engine behind automatic dependency injection. You don't have to manually wire up dependencies in most cases -- the framework does it for you.

Automatic Constructor Injection

When Laravel instantiates a controller, a command, an event listener, or any class resolved through the container, it reads the constructor's type hints and automatically injects the appropriate implementations:

// app/Http/Controllers/PaymentController.php

class PaymentController extends Controller
{
    public function __construct(
        private PaymentService $paymentService,
        private LoggerInterface $logger,
    ) {}

    public function store(Request $request): JsonResponse
    {
        $result = $this->paymentService->process($request->validated());
        $this->logger->info('Payment processed', ['result' => $result]);
        return response()->json($result);
    }
}

You never write new PaymentController(new PaymentService(...), new Logger(...)). Laravel's container resolves the entire dependency tree automatically. If PaymentService itself requires a GatewayInterface, the container resolves that too -- all the way down.

Binding Interfaces to Implementations

When a class depends on an interface (not a concrete class), you need to tell the container which implementation to use. This is done in a Service Provider:

// app/Providers/AppServiceProvider.php

use App\Contracts\MailerInterface;
use App\Mail\SmtpMailer;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Whenever someone asks for MailerInterface, give them SmtpMailer
        $this->app->bind(MailerInterface::class, SmtpMailer::class);
    }
}

Now any constructor that type-hints MailerInterface will receive an SmtpMailer instance, without the consuming class knowing about the concrete implementation.

Bind vs Singleton

// New instance every time it's resolved
$this->app->bind(MailerInterface::class, SmtpMailer::class);

// Same instance reused for the entire request lifecycle
$this->app->singleton(MailerInterface::class, SmtpMailer::class);

Use singleton for services that are expensive to create or should maintain state within a single request (like a database connection). Use bind when you want a fresh instance each time.

Contextual Binding

Sometimes different classes need different implementations of the same interface:

$this->app->when(OrderNotification::class)
    ->needs(MailerInterface::class)
    ->give(SmtpMailer::class);

$this->app->when(AlertNotification::class)
    ->needs(MailerInterface::class)
    ->give(SmsMailer::class);

OrderNotification gets emails; AlertNotification gets SMS. Same interface, different implementations, resolved automatically.

Method Injection in Routes and Controllers

Laravel also supports method-level injection in controller actions. The framework inspects the method signature and injects resolved instances:

class ReportController extends Controller
{
    // Request is injected automatically
    public function generate(Request $request, ReportService $reportService): Response
    {
        return $reportService->build($request->input('type'));
    }
}

The Container Itself: Illuminate\Container\Container

Under the hood, Laravel's service container is Illuminate\Container\Container. Key methods you should know:

// Resolve an instance
$mailer = app(MailerInterface::class);

// Check if a binding exists
app()->bound(MailerInterface::class); // true or false

// Resolve with parameters
app()->makeWith(SmtpMailer::class, ['host' => 'smtp.example.com']);

The app() helper function is a convenience wrapper around the container. In service providers, you use $this->app instead.

Real-World Example: Laravel's Mail System

Laravel's own mail system is a textbook example of DI. The Illuminate\Mail\MailManager class doesn't create mailers directly. Instead, it uses driver configuration to resolve the right transport:

// config/mail.php defines the driver (smtp, ses, mailgun, log, array...)
// MailManager resolves the right Transport implementation
// Your code only depends on the Mailable contract

When you call Mail::to($user)->send(new WelcomeEmail()), the facade resolves the MailManager from the container, which in turn resolves the configured transport. Your application code never touches transport details.

Testing with the Container

In tests, you can swap implementations effortlessly:

// In a Laravel test
$this->app->bind(MailerInterface::class, FakeMailer::class);

// Now any code that resolves MailerInterface gets FakeMailer
$response = $this->post('/api/notify', ['to' => 'test@example.com']);

Laravel also provides $this->mock() and $this->partialMock() which bind mocked instances into the container automatically.


Key takeaway: Laravel's service container eliminates the manual wiring of dependencies. You define contracts (interfaces), register bindings in service providers, and let the container handle the rest. This is why Laravel controllers stay clean -- they declare what they need, and the framework provides it.

This example is pre-filled and runnable. Click "Run" to see the output. Feel free to modify the code and experiment.

Output

                

            

Complete the code below to pass all test cases. Use the hints if you get stuck.