Gateway Layer
Gateways are the exclusive bridge between your application and external services — payment processors, SMS providers, weather APIs, or any vendor SDK. A gateway is responsible for calling a vendor and translating between your domain objects and the vendor's API. Gateways only talk to vendor SDKs — they never access the database, call repositories, or bypass the HTTP client port.
Gateway Rules
- Implement ONE per vendor concern —
StripePaymentGateway,TwilioSmsGateway - Depend on
HttpClientPortonly — for HTTP calls; pass vendor SDKs if necessary but via the port's abstractions - Inject
HttpClientPort::pending()for outbound HTTP — never use a concrete HTTP client - Translate EVERY vendor exception to
GatewayException— never let vendor errors escape - Handle all vendor error types explicitly — catch every exception the vendor can throw
- Return a public domain response type — controllers and services depend on YOUR types, not vendor types
Complete Example
<?php declare(strict_types=1);
namespace Shop\Payment\Infrastructure\Gateways;
use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HttpClientPort;
use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\GatewayException;
use Shop\Payment\API\Contracts\PaymentGatewayContract;
use Shop\Payment\Domain\ValueObjects\Money;
final class StripePaymentGateway implements PaymentGatewayContract
{
private readonly string $baseUrl = 'https://api.stripe.com/v1';
public function __construct(
private readonly HttpClientPort $http,
private readonly string $secretKey,
) {}
/**
* Create a payment intent with Stripe.
*/
public function charge(ChargeDTO $dto): ChargeResult
{
try {
$response = $this->http->pending()
->baseUrl($this->baseUrl)
->withBasicAuth($this->secretKey, '') // Stripe expects secret as username, empty password
->asForm()
->post('/payment_intents', [
'amount' => $dto->amount->amount(), // in cents
'currency' => strtolower($dto->amount->currency()),
'payment_method' => $dto->paymentMethodId,
'confirm' => 'true',
'return_url' => $dto->returnUrl,
]);
$response->throw('gateway.stripe.charge'); // Throw on 4xx/5xx
$data = $response->json();
return match ($data['status'] ?? null) {
'requires_action' => ChargeResult::requiresAction(
$data['client_secret'],
$data['id'],
),
'succeeded' => ChargeResult::success($data['id']),
'processing' => ChargeResult::processing($data['id']),
default => ChargeResult::failed($data['status'] ?? 'unknown'),
};
} catch (GatewayException $e) {
// Already translated; re-throw
throw $e;
} catch (\Throwable $e) {
// Unexpected error; wrap it
throw new GatewayException(
'Stripe charge failed: ' . $e->getMessage(),
layer: 'gateway.stripe.charge',
context: ['payment_method' => $dto->paymentMethodId],
previous: $e,
);
}
}
/**
* Retrieve a payment intent from Stripe.
*/
public function retrieve(string $intentId): ?PaymentIntentDTO
{
try {
$response = $this->http->pending()
->baseUrl($this->baseUrl)
->withBasicAuth($this->secretKey, '')
->get("/payment_intents/{$intentId}");
$response->throw('gateway.stripe.retrieve');
$data = $response->json();
return new PaymentIntentDTO(
id: $data['id'],
status: $data['status'],
amount: Money::cents((int) $data['amount'], $data['currency']),
);
} catch (GatewayException $e) {
throw $e;
} catch (\Throwable $e) {
throw new GatewayException(
'Failed to retrieve payment intent from Stripe.',
layer: 'gateway.stripe.retrieve',
context: ['intent_id' => $intentId],
previous: $e,
);
}
}
}HttpClientPort and PendingRequestContract
The HttpClientPort provides a fluent, immutable request builder for making outbound HTTP calls. Every method returns a new instance, so you can build reusable templates.
Creating a Pending Request
$pending = $this->http->pending()
->baseUrl('https://api.example.com')
->withToken('sk_live_abc123', 'Bearer')
->asJson()
->timeout(10)
->retry(3);
// Now use it to make requests
$response = $pending->post('/invoices', ['amount' => 9999]);Available Methods
| Method | Purpose |
|---|---|
baseUrl(string) | Set the base URL; endpoints are appended to it |
withHeaders(array) | Add multiple headers at once |
withHeader(string, string) | Add a single header |
withToken(string, string) | Add Authorization: <type> <token> (e.g., Bearer, Basic) |
withBasicAuth(string, string) | Add HTTP Basic authentication |
asJson() | Send Content-Type: application/json |
asForm() | Send Content-Type: application/x-www-form-urlencoded |
asMultipart() | Switch to multipart/form-data (required before attach()) |
attach(string, string, ?string) | Attach a file to a multipart request |
acceptJson() | Add Accept: application/json header |
timeout(int) | Set request timeout in seconds |
connectTimeout(int) | Set connection timeout in seconds |
retry(int) | Auto-retry failed requests this many times |
retryMethods(array) | Override which HTTP methods are retried (default: idempotent) |
get(string, array) | Send GET request |
post(string, array) | Send POST request |
put(string, array) | Send PUT request |
patch(string, array) | Send PATCH request |
delete(string, array) | Send DELETE request |
send(string, string, array) | Send custom method |
HttpClientResponse
Responses are immutable value objects:
$response->status(): int // HTTP status code
$response->body(): string // Raw response body
$response->header(string): ?string // Single header (case-insensitive)
$response->headers(): array // All headers as [name => value]
$response->json() // Decode JSON body; null if invalid
$response->ok(): bool // Status 200-299?
$response->redirect(): bool // Status 300-399?
$response->clientError(): bool // Status 400-499?
$response->serverError(): bool // Status 500+?
$response->failed(): bool // Status 400+?
$response->throw(?string): self // Throw GatewayException on failureGateway Contracts
Gateways implement contracts that define what they promise to callers. A contract is a PHP interface that describes the gateway's public methods and return types.
<?php declare(strict_types=1);
namespace Shop\Payment\API\Contracts;
use Shop\Payment\Domain\ValueObjects\Money;
interface PaymentGatewayContract
{
public function charge(ChargeDTO $dto): ChargeResult;
public function retrieve(string $intentId): ?PaymentIntentDTO;
}Result DTOs
Gateways return result DTOs (not vendor objects) so callers don't couple to vendor APIs.
<?php declare(strict_types=1);
namespace Shop\Payment\Infrastructure\Gateways;
use Shop\Payment\Domain\ValueObjects\Money;
final class ChargeResult
{
public function __construct(
public readonly string $status, // 'success', 'failed', 'requires_action', 'processing'
public readonly string $vendorId,
public readonly ?string $clientSecret = null, // For 3D Secure flows
) {}
public static function success(string $vendorId): self
{
return new self('success', $vendorId);
}
public static function failed(string $status): self
{
return new self('failed', '', clientSecret: $status);
}
public static function requiresAction(string $clientSecret, string $vendorId): self
{
return new self('requires_action', $vendorId, clientSecret: $clientSecret);
}
public static function processing(string $vendorId): self
{
return new self('processing', $vendorId);
}
public function isSuccessful(): bool { return $this->status === 'success'; }
public function isPending(): bool { return $this->status === 'processing'; }
}Testing Gateways
Gateways depend on HttpClientPort, which is an interface. In tests, inject a fake client that returns canned responses. The kernel does not ship one; a minimal fake is a few lines:
<?php declare(strict_types=1);
namespace Tests\Fakes;
use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\{HttpClientPort, HttpClientResponse, PendingRequestContract};
final class FakeHttpClient implements HttpClientPort
{
/** @var array<string, HttpClientResponse> "METHOD url-suffix" => response */
private array $responses = [];
/** @var list<array{string, string, array}> */
public array $sent = [];
public function expect(string $method, string $urlSuffix, HttpClientResponse $response): void
{
$this->responses[strtoupper($method) . ' ' . $urlSuffix] = $response;
}
public function request(string $method, string $url, array $options = []): HttpClientResponse
{
$this->sent[] = [strtoupper($method), $url, $options];
foreach ($this->responses as $key => $response) {
[$m, $suffix] = explode(' ', $key, 2);
if ($m === strtoupper($method) && str_ends_with($url, $suffix)) {
return $response;
}
}
return new HttpClientResponse(404, '');
}
public function get(string $url, array $query = []): HttpClientResponse { return $this->request('GET', $url, ['query' => $query]); }
public function post(string $url, array $data = []): HttpClientResponse { return $this->request('POST', $url, ['json' => $data]); }
public function put(string $url, array $data = []): HttpClientResponse { return $this->request('PUT', $url, ['json' => $data]); }
public function patch(string $url, array $data = []): HttpClientResponse { return $this->request('PATCH', $url, ['json' => $data]); }
public function delete(string $url, array $data = []): HttpClientResponse { return $this->request('DELETE', $url, ['json' => $data]); }
public function pending(): PendingRequestContract
{
throw new \LogicException('This fake does not support pending(); fake PendingRequestContract too if the gateway uses it.');
}
}Then:
<?php declare(strict_types=1);
namespace Tests\Unit\Payment\Gateways;
use PHPUnit\Framework\TestCase;
use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HttpClientResponse;
use Shop\Payment\Infrastructure\Gateways\StripePaymentGateway;
use Tests\Fakes\FakeHttpClient;
final class StripePaymentGatewayTest extends TestCase
{
public function testChargingSucceeds(): void
{
$fakeHttp = new FakeHttpClient();
$fakeHttp->expect('post', '/payment_intents', new HttpClientResponse(
status: 200,
body: json_encode(['id' => 'pi_123', 'status' => 'succeeded']),
));
$gateway = new StripePaymentGateway($fakeHttp, 'sk_test_abc');
$result = $gateway->charge(new ChargeDTO(
amount: Money::of(99.99, 'USD'),
paymentMethodId: 'pm_test',
returnUrl: 'https://example.test/success',
));
$this->assertTrue($result->isSuccessful());
$this->assertEquals('pi_123', $result->vendorId);
}
}Common Mistakes
✗ Returning Vendor Objects
// WRONG — callers now depend on Stripe's API
public function charge(ChargeDTO $dto): \Stripe\PaymentIntent
{
return $this->stripe->paymentIntents->create([...]);
}✓ Return Domain Result DTOs
// RIGHT — callers depend only on your contract
public function charge(ChargeDTO $dto): ChargeResult
{
$intent = $this->http->post(...);
return ChargeResult::success($intent['id']);
}✗ Direct Vendor SDK Without HttpClientPort
// WRONG — tightly coupled to Stripe; untestable
final class StripePaymentGateway
{
public function __construct(private \Stripe\StripeClient $stripe) {}
public function charge(ChargeDTO $dto)
{
// Cannot inject a fake Stripe client for tests
}
}✓ Use HttpClientPort
// RIGHT — loosely coupled; testable with a fake port
final class StripePaymentGateway
{
public function __construct(private HttpClientPort $http) {}
public function charge(ChargeDTO $dto)
{
$response = $this->http->pending()->post(...);
// Can inject FakeHttpClient in tests
}
}✗ Vendor Exceptions Escape
// WRONG — caller catches vendor-specific exceptions
try {
$gateway->charge($dto);
} catch (\Stripe\Exception\CardException $e) {
// Bound to Stripe implementation
}✓ Translate to GatewayException
// RIGHT — uniform exception type
try {
$gateway->charge($dto);
} catch (GatewayException $e) {
// Works regardless of vendor
}