- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have one PaymentGateway interface. Two things need it. The checkout controller needs the real Stripe adapter. The internal admin tool that lets support agents issue test refunds needs a sandbox adapter that never touches a live card. Same type-hint, two consumers, two different implementations.
The usual answer is a flag inside the class: if ($this->sandbox) { ... }. Now your gateway knows about the two callers. The next answer is two interfaces, PaymentGateway and SandboxPaymentGateway, which duplicates the contract and leaks the environment into your type system. Both are the wrong shape.
Laravel's container already solves this. It is called contextual binding, and the entry point is when()->needs()->give().
The port and its two adapters
Start with the contract. One interface, no environment awareness:
<?php
namespace App\Payments;
interface PaymentGateway
{
public function charge(int $cents, string $token): string;
}
Two adapters implement it. The real one talks to Stripe:
<?php
namespace App\Payments;
use Stripe\StripeClient;
final class StripeGateway implements PaymentGateway
{
public function __construct(
private readonly StripeClient $stripe,
) {}
public function charge(int $cents, string $token): string
{
$charge = $this->stripe->charges->create([
'amount' => $cents,
'currency' => 'usd',
'source' => $token,
]);
return $charge->id;
}
}
The sandbox one never leaves the process:
<?php
namespace App\Payments;
final class SandboxGateway implements PaymentGateway
{
public function charge(int $cents, string $token): string
{
// no network, no side effects, deterministic id
return 'sandbox_' . bin2hex(random_bytes(8));
}
}
Both satisfy the same type-hint. Neither knows who calls it. That is the whole point of a port: the domain depends on the shape, not on which box the shape came from.
Wiring both behind one interface
Two consumers. The checkout flow, which must hit the real gateway:
<?php
namespace App\Http\Controllers;
use App\Payments\PaymentGateway;
final class CheckoutController
{
public function __construct(
private readonly PaymentGateway $gateway,
) {}
public function store(): string
{
return $this->gateway->charge(4999, request('token'));
}
}
And the admin tool, which must not:
<?php
namespace App\Http\Controllers\Admin;
use App\Payments\PaymentGateway;
final class TestRefundController
{
public function __construct(
private readonly PaymentGateway $gateway,
) {}
public function store(): string
{
return $this->gateway->charge(100, 'tok_test');
}
}
Identical constructors. The container decides which adapter each one receives. That decision lives in a service provider, and nowhere else:
<?php
namespace App\Providers;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\Admin\TestRefundController;
use App\Payments\PaymentGateway;
use App\Payments\SandboxGateway;
use App\Payments\StripeGateway;
use Illuminate\Support\ServiceProvider;
final class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
// default binding for anyone else asking
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// per-consumer override
$this->app->when(CheckoutController::class)
->needs(PaymentGateway::class)
->give(StripeGateway::class);
$this->app->when(TestRefundController::class)
->needs(PaymentGateway::class)
->give(SandboxGateway::class);
}
}
Read when()->needs()->give() as one sentence. When the container is building TestRefundController, and it needs a PaymentGateway, give it a SandboxGateway. The controllers stay clean. The two adapters stay independent. The routing decision sits in the composition root, which is exactly where a wiring decision belongs.
give() takes a closure too
give() accepts a class name, but it also accepts a closure. That matters when the adapter needs runtime configuration the container can't infer:
$this->app->when(CheckoutController::class)
->needs(PaymentGateway::class)
->give(function ($app) {
return new StripeGateway(
new \Stripe\StripeClient(config('services.stripe.secret')),
);
});
The closure runs every time the container resolves that consumer, so it stays lazy. Nothing constructs a StripeClient until a request actually reaches checkout. The admin tool, resolved on a different route, never builds one.
There is a shorthand for the common case of injecting config rather than a whole object. giveConfig() reads a config key straight into a scalar parameter:
$this->app->when(StripeGateway::class)
->needs('$currency')
->giveConfig('services.stripe.currency');
The $currency syntax with the dollar sign targets a primitive constructor argument by name, not a class. That is how you feed a plain string or int to one specific class without a global config lookup inside it.
The test wiring is the same move
The reason this pattern pays off is that tests are just another consumer. A feature test does not want the real Stripe adapter anywhere near it. With contextual binding already in place, swapping the implementation is one line in the test's setup:
<?php
use App\Payments\PaymentGateway;
use App\Payments\SandboxGateway;
it('completes checkout without hitting stripe', function () {
$this->app->bind(PaymentGateway::class, SandboxGateway::class);
$response = $this->post('/checkout', ['token' => 'tok_x']);
$response->assertOk();
});
Because the controller depends on the interface, the test rebinds the interface and the controller never notices. No mock framework required for the happy path. When you do want to assert on calls, bind a hand-written fake or a Mockery double against the same interface:
$fake = Mockery::mock(PaymentGateway::class);
$fake->shouldReceive('charge')
->once()
->with(4999, 'tok_x')
->andReturn('ch_fake');
$this->app->instance(PaymentGateway::class, $fake);
The class under test asked for a port. The test decides which adapter fills it. Nothing about the production wiring changed.
When contextual binding is the wrong tool
It earns its place when two consumers genuinely need different implementations of the same contract. It is the wrong tool when the difference is data, not behavior. If checkout and the admin tool both use Stripe but with different API keys, that is a config problem, not a binding problem. Resolve it with giveConfig() or a factory, and keep a single adapter.
It is also the wrong tool when the branching is per-request rather than per-consumer. If the gateway depends on the authenticated user's plan, the container has no idea about that at build time. Reach for a resolver or a strategy the class asks at runtime, not contextual binding.
The signal that you want it: two constructors with the same type-hint that must never share an implementation. The moment you catch yourself adding a boolean flag to a class so it can behave two ways, stop. That flag is a binding decision that escaped the composition root.
Where the decision belongs
Contextual binding works because it keeps a wiring concern out of your classes. The adapter does one thing. The consumer asks for a contract. The provider decides who gets what, in one file you can read top to bottom. That separation is the same one clean and hexagonal architecture pushes everywhere: the domain depends on ports, adapters are swappable at the edge, and the choice of adapter lives in the composition root, not smuggled into a service via an if. Decoupled PHP is about building that seam on purpose so the framework's container stays the thing that wires your app, not the thing your app is built out of.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)