0

DIP in Laravel — binding interfaces to implementations

Intermediate5 min read·eng-01-011
solidlaravel-src

Concept

Laravel is designed around DIP. Its entire architecture — service container, contracts namespace, service providers — exists to make it trivial to depend on abstractions instead of concretions. Once you understand this, the framework's design starts to feel like a natural consequence of applying DIP at scale.

The Illuminate\Contracts namespace is Laravel's library of interfaces for every major framework service: Cache\Repository, Queue\Queue, Mail\Mailer, Auth\Guard, Filesystem\Filesystem, and dozens more. These interfaces are the abstractions. The concrete implementations live in Illuminate\Cache, Illuminate\Queue, etc. Your application code should reference the contracts, not the concrete classes.

The workflow for applying DIP in a Laravel application:

  1. Create an interface in your domain or application layer (e.g., App\Contracts\PaymentGateway)
  2. Implement it in your infrastructure layer (e.g., App\Infrastructure\Stripe\StripePaymentGateway)
  3. Bind it in a service provider: $this->app->bind(PaymentGateway::class, StripePaymentGateway::class)
  4. Inject it via constructor type-hint — the container resolves the concrete automatically
  5. Swap it by changing the binding — application code never changes

This pattern also makes testing straightforward: bind a fake or mock in your TestCase::setUp() and the entire application uses your test double without any production code knowing.

Code Example

php
<?php
declare(strict_types=1);

// Step 1: Define the contract in the application layer
// File: app/Contracts/PaymentGateway.php
namespace App\Contracts;

interface PaymentGateway
{
    public function charge(float $amountInCents, string $currency, string $paymentMethodId): ChargeResult;
    public function refund(string $chargeId, float $amountInCents): void;
}

// Step 2: Implement in infrastructure
// File: app/Infrastructure/Stripe/StripePaymentGateway.php
namespace App\Infrastructure\Stripe;

use App\Contracts\PaymentGateway;
use Stripe\StripeClient;

final class StripePaymentGateway implements PaymentGateway
{
    public function __construct(private readonly StripeClient $stripe) {}

    public function charge(float $amountInCents, string $currency, string $paymentMethodId): ChargeResult
    {
        $intent = $this->stripe->paymentIntents->create([
            'amount'               => (int) $amountInCents,
            'currency'             => $currency,
            'payment_method'       => $paymentMethodId,
            'confirm'              => true,
            'return_url'           => config('app.url') . '/payments/return',
        ]);

        return new ChargeResult(
            chargeId: $intent->id,
            status:   $intent->status,
        );
    }

    public function refund(string $chargeId, float $amountInCents): void
    {
        $this->stripe->refunds->create([
            'payment_intent' => $chargeId,
            'amount'         => (int) $amountInCents,
        ]);
    }
}

// Step 3: Bind in a service provider
// File: app/Providers/PaymentServiceProvider.php
namespace App\Providers;

use App\Contracts\PaymentGateway;
use App\Infrastructure\Stripe\StripePaymentGateway;
use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(StripeClient::class, function (): StripeClient {
            return new StripeClient(config('services.stripe.secret'));
        });

        // Bind the interface to the concrete implementation
        $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
    }
}

// Step 4: Inject the interface — Laravel resolves the concrete automatically
// File: app/Actions/CheckoutAction.php
namespace App\Actions;

use App\Contracts\PaymentGateway;

final class CheckoutAction
{
    public function __construct(
        private readonly PaymentGateway   $payments,
        private readonly OrderRepository  $orders,
    ) {}

    public function execute(Cart $cart, string $paymentMethodId): Order
    {
        $result = $this->payments->charge(
            amountInCents:   $cart->totalInCents(),
            currency:        'usd',
            paymentMethodId: $paymentMethodId,
        );

        return $this->orders->createFromCart($cart, $result->chargeId);
    }
}

// Step 5: In tests, swap the binding — no production code changes
// File: tests/Feature/CheckoutTest.php
class CheckoutTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Swap Stripe for a fake — CheckoutAction never knows
        $this->app->bind(PaymentGateway::class, FakePaymentGateway::class);
    }

    public function test_checkout_creates_an_order(): void
    {
        $cart = Cart::factory()->withItems(3)->create();

        $action = app(CheckoutAction::class);
        $order  = $action->execute($cart, 'pm_test_card');

        $this->assertDatabaseHas('orders', ['cart_id' => $cart->id]);
    }
}

Interview Q&A

Q: What is the Illuminate\Contracts namespace and why should you use it?

Illuminate\Contracts contains interface definitions for every major Laravel service — cache, queue, mail, filesystem, authentication, events, and more. Using these interfaces instead of concrete facade classes means your code depends on the contract, not the implementation. This makes it trivial to swap drivers (from file cache to Redis, from database queue to SQS), run tests without real infrastructure, and use your code with alternative implementations. Cache\Repository instead of Illuminate\Cache\Repository; Mail\Mailer instead of Illuminate\Mail\Mailer. The concrete classes satisfy the contracts, but your code never needs to reference them directly.


Q: How does contextual binding in Laravel relate to DIP?

Contextual binding handles the case where you have multiple concrete implementations of the same interface and different classes need different ones. For example: a ReportMailer should use the SES driver, but an AlertMailer should use the Mailgun driver. Both implement Mailer. Without contextual binding, the container can only resolve one concrete per interface. With contextual binding: $this->app->when(ReportMailer::class)->needs(Mailer::class)->give(SesMailer::class). This is DIP applied to the resolution layer — both classes depend on the Mailer abstraction, but the container injects the correct concrete for each context.


Q: When is it acceptable to depend directly on a concrete class instead of an interface?

Two cases: (1) When the concrete class is stable, has no variants you would ever swap, and is not infrastructure. Depending on PHP's DateTime or Laravel's Collection directly is fine — they are stable value types with no swappable alternatives. (2) When the cost of abstraction exceeds its benefit. A simple LogFormatter that is genuinely never going to be swapped does not need an interface. Apply the abstractions at the seams between your domain and external systems (databases, APIs, email providers, payment gateways, filesystems) — these are the points of volatility where DIP pays the highest dividend.