A payment, shipping, or CRM SDK is an external language. Its objects, error types, payload fields, release cadence, and HTTP assumptions are useful at the edge of an application. They become costly when they appear in controllers, jobs, database models, and notifications. An SDK upgrade then becomes a codebase-wide migration, while a vendor error can accidentally become customer copy.
The Adapter pattern gives that edge one owner. The application calls a small contract in its own language. One implementation maps that contract to the vendor SDK or API and maps the result back. The purpose is not to conceal every Composer package. It is to own a seam when a dependency is business-critical, used by several use cases, operationally complicated, or likely to change.
This example buys a shipping label. It includes the usually omitted decisions: timeouts, idempotency, exception mapping, safe logs, a container binding, and HTTP-faked tests.
The leak starts innocently
Direct use is understandable on day one:
<?php
declare(strict_types=1);
namespace App\\Http\\Controllers;
use AcmeShip\\Client;
use AcmeShip\\Exception\\ApiException;
use App\\Models\\Order;
final class BuyShippingLabelController
{
public function __invoke(Order $order, Client $client): void
{
try {
$label = $client->labels()->create([
'recipient' => ['name' => $order->shipping_name],
'parcel' => ['weight_grams' => $order->weight_grams],
]);
} catch (ApiException $exception) {
abort(422, $exception->getMessage());
}
$order->update(['tracking_number' => $label->tracking_code]);
}
}
The second caller copies the mapping. A queued retry must learn which vendor
exception is temporary. A carrier change becomes a search for AcmeShip.
There is also no coherent answer to a simple domain question: is a rejected
postcode different from an unavailable carrier? The controller knows details it
should never have needed to know.
Do not react by wrapping a stable, two-line utility used once. Direct use is clearer there. Add the adapter when the integration has enough change or risk to deserve a protected boundary.
Make the contract speak domain language
The contract exposes the operation, not the vendor's entire catalogue of options. Immutable values replace fragile associative arrays, and no caller needs an Eloquent model or a vendor response object to invoke it.
<?php
declare(strict_types=1);
namespace App\\Shipping;
final readonly class CreateShipment
{
public function __construct(
public string $orderId,
public string $recipientName,
public string $addressLineOne,
public string $postalCode,
public string $countryCode,
public int $weightGrams,
) {
if ($weightGrams < 1) {
throw new \\InvalidArgumentException('Shipment weight must be positive.');
}
}
}
final readonly class ShipmentLabel
{
public function __construct(
public string $trackingNumber,
public string $downloadUrl,
) {}
}
interface ShippingGateway
{
public function buyLabel(CreateShipment $shipment): ShipmentLabel;
}
Failures belong to that contract as well. Callers should not catch a vendor namespace or parse a message. A controlled rejection is actionable; an outage is retryable. Keep any machine-readable reason deliberately small rather than passing through arbitrary upstream text.
<?php
declare(strict_types=1);
namespace App\\Shipping\\Exceptions;
use RuntimeException;
final class ShipmentRejected extends RuntimeException
{
public function __construct(public readonly string $reason)
{
parent::__construct('The carrier rejected this shipment.');
}
}
final class ShippingGatewayUnavailable extends RuntimeException
{
public function __construct()
{
parent::__construct('Shipping labels are temporarily unavailable.');
}
}
Translate once at the vendor boundary
Whether a vendor distributes an SDK or only an HTTP API does not change the application-facing interface. This implementation uses Laravel's HTTP factory so the transport policy is visible and testable. With a mandatory SDK, make that SDK call inside this class; do not let it leak past the interface.
<?php
declare(strict_types=1);
namespace App\\Shipping;
use App\\Shipping\\Exceptions\\ShipmentRejected;
use App\\Shipping\\Exceptions\\ShippingGatewayUnavailable;
use Illuminate\\Http\\Client\\ConnectionException;
use Illuminate\\Http\\Client\\Factory;
use Illuminate\\Http\\Client\\RequestException;
use Illuminate\\Support\\Facades\\Log;
final class AcmeShippingGateway implements ShippingGateway
{
public function __construct(
private readonly Factory $http,
private readonly string $baseUrl,
private readonly string $apiToken,
) {}
public function buyLabel(CreateShipment $shipment): ShipmentLabel
{
try {
$response = $this->http->baseUrl($this->baseUrl)
->acceptJson()->withToken($this->apiToken)
->connectTimeout(3)->timeout(10)->retry(2, 200, throw: false)
->withHeaders(['Idempotency-Key' => 'shipping-label-'.$shipment->orderId])
->post('/v1/labels', [
'recipient' => [
'name' => $shipment->recipientName,
'address_line_1' => $shipment->addressLineOne,
'postal_code' => $shipment->postalCode,
'country' => $shipment->countryCode,
],
'parcel' => ['weight_grams' => $shipment->weightGrams],
]);
} catch (ConnectionException $exception) {
report($exception);
throw new ShippingGatewayUnavailable();
}
if ($response->unprocessableEntity()) {
$reason = $response->json('error.code', 'invalid_shipment');
Log::notice('Carrier rejected shipment.', ['order_id' => $shipment->orderId, 'reason' => $reason]);
throw new ShipmentRejected($reason);
}
try {
$response->throw();
} catch (RequestException $exception) {
report($exception);
throw new ShippingGatewayUnavailable();
}
return new ShipmentLabel(
trackingNumber: $response->json('data.tracking_number'),
downloadUrl: $response->json('data.label_url'),
);
}
}
Explicit timeouts matter: Laravel's default response timeout is longer than a web request should generally wait for a carrier. Retrying is safe only if the vendor honours the stable idempotency key. A connection can fail after a label was created; without that guarantee, a retry can buy a second label. Verify the vendor's actual semantics before treating a header name as protection.
The adapter retries a transport attempt, not the whole business operation. A job should own delayed retry policy because it knows whether the order can wait. Never automatically retry a 422: an invalid address needs correction. Likewise, do not map every 4xx to an outage. Authentication needs an operator alert, a 429 may need queue backoff, and validation is a workflow outcome.
Bind the contract at the composition root
Only the service provider decides which production carrier implements the
contract. Configuration stays in config; env() does not spread through
application code.
<?php
declare(strict_types=1);
namespace App\\Providers;
use App\\Shipping\\AcmeShippingGateway;
use App\\Shipping\\ShippingGateway;
use Illuminate\\Support\\ServiceProvider;
final class ShippingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(ShippingGateway::class, function ($app): ShippingGateway {
return new AcmeShippingGateway(
http: $app->make('http'),
baseUrl: config('services.acme_shipping.base_url'),
apiToken: config('services.acme_shipping.token'),
);
});
}
}
An action can now persist the application value without seeing vendor types:
<?php
declare(strict_types=1);
namespace App\\Actions\\Shipping;
use App\\Models\\Order;
use App\\Shipping\\CreateShipment;
use App\\Shipping\\ShippingGateway;
final class PurchaseOrderLabel
{
public function __construct(private readonly ShippingGateway $gateway) {}
public function handle(Order $order): void
{
$label = $this->gateway->buyLabel(new CreateShipment(
orderId: (string) $order->getKey(), recipientName: $order->shipping_name,
addressLineOne: $order->shipping_address_line_1, postalCode: $order->shipping_postal_code,
countryCode: $order->shipping_country, weightGrams: $order->weight_grams,
));
$order->update(['tracking_number' => $label->trackingNumber, 'shipping_label_url' => $label->downloadUrl]);
}
}
Do not use a singleton simply to save an allocation if credentials, tenant headers, or mutable state can vary. A singleton is appropriate only after the concrete client is shown to be stateless and safe for the running process model.
Prove the translation with HTTP fakes
Adapter tests assert the URL, payload, idempotency key, response mapping, and
error mapping. preventStrayRequests() ensures a missing fake cannot call a
real carrier. Use a simple in-memory ShippingGateway fake in action tests so
those tests verify persistence rather than repeat transport assertions.
<?php
use App\\Shipping\\AcmeShippingGateway;
use App\\Shipping\\CreateShipment;
use App\\Shipping\\Exceptions\\ShipmentRejected;
use Illuminate\\Http\\Client\\Request;
use Illuminate\\Support\\Facades\\Http;
it('maps a carrier response and sends an idempotency key', function () {
Http::preventStrayRequests();
Http::fake(['https://shipping.example.test/v1/labels' => Http::response([
'data' => ['tracking_number' => 'ACME-123', 'label_url' => 'https://labels.test/ACME-123.pdf'],
], 201)]);
$gateway = new AcmeShippingGateway(Http::getFacadeRoot(), 'https://shipping.example.test', 'token');
expect($gateway->buyLabel(new CreateShipment('42', 'Ada', '1 Example Street', '00-001', 'PL', 500))->trackingNumber)
->toBe('ACME-123');
Http::assertSent(fn (Request $request): bool => $request->hasHeader('Idempotency-Key', 'shipping-label-42'));
});
it('maps a carrier validation error to a rejection', function () {
Http::fake(['https://shipping.example.test/*' => Http::response(['error' => ['code' => 'invalid_postal_code']], 422)]);
$gateway = new AcmeShippingGateway(Http::getFacadeRoot(), 'https://shipping.example.test', 'token');
expect(fn () => $gateway->buyLabel(new CreateShipment('42', 'Ada', 'Street', 'bad', 'PL', 500)))
->toThrow(ShipmentRejected::class);
});
Add an equivalent Http::failedConnection() test for
ShippingGatewayUnavailable. Neither test should name a vendor exception.
Keep workflow state and delivery state separate
Buying a label has two different kinds of state. The carrier owns whether it accepted a purchase and which tracking number it assigned. The application owns whether an order is ready to fulfil, has a pending label purchase, or is safe to hand to a warehouse. Treating a successful HTTP response as if it atomically updated both systems is a common source of duplicate labels and orders that look shipped without a label.
For a web checkout, persist the order in a label_pending state and dispatch a
job after the database transaction commits. The job invokes the gateway and
stores the returned ShipmentLabel in a second, short transaction. A temporary
gateway failure leaves the pending state in place for a queued retry; a
ShipmentRejected moves the order to an actionable state that support can
inspect. The adapter should not dispatch that job or decide those states. Its
single responsibility is translating one attempted purchase. Keeping that
policy outside the adapter makes a synchronous admin flow and an asynchronous
checkout flow able to share the same boundary.
This separation also gives operations a recovery path. Record the stable idempotency key or application shipment id alongside the order, and surface it in logs and support tooling. If a worker dies after the carrier accepts the request but before the database write, a retry can ask the carrier with the same key rather than guessing whether to create another label. If the provider does not offer safe idempotency or lookup, the right answer may be a manual reconciliation queue, not an optimistic automatic retry.
Operational pitfalls and alternatives
Do not return vendor response objects from the interface. That adds ceremony without removing coupling. Do not log tokens, full addresses, raw responses, or label PDFs by default; an order id, controlled reason, HTTP status, and vendor request id are normally enough. An adapter also does not solve distributed transactions: do not mark an order shipped before the label exists, and do not expect a database rollback to undo a carrier purchase. Persist a pending state and make retries observable.
When several carriers implement the same operation, select several adapters
with a resolver or Strategy. Do not create one enormous adapter with
if ($carrier === ...) branches. Conversely, if a well-typed SDK is used only
inside one isolated infrastructure class, that class may already be the useful
boundary. The test is whether a controller, action, or job can be read without
knowing a vendor class or error. If it can, the seam is owned.