Infrastructure Layer
The Infrastructure layer implements Domain Contracts. It contains everything that talks to the outside world — Stripe, Socialite, push notification services — isolated behind interfaces so your business logic never depends on a specific vendor.
backend/app/Infrastructure/
Directory Structure
Infrastructure/
├── Auth/
│ └── Providers/
│ └── Socialite/
│ └── SocialiteOAuthProvider.php # OAuthProvider implementation
├── Billing/
│ ├── Mappers/
│ │ └── TaxBreakdownMapper.php # Stripe invoice → TaxRecordDraft[]
│ ├── Providers/
│ │ └── Stripe/
│ │ ├── StripeClient.php # Stripe SDK wrapper
│ │ ├── StripeConfig.php # Configuration management
│ │ ├── StripePaymentGateway.php # PaymentGatewayInterface impl
│ │ └── StripeTaxProvider.php # TaxProviderInterface impl
│ ├── Queries/
│ │ ├── LocalInvoiceQuery.php # Query internal invoices
│ │ └── StripeInvoiceQuery.php # Query Stripe invoices
│ ├── Services/
│ │ ├── InternalBillingCalculator.php # BillingCalculatorInterface impl
│ │ ├── InternalInvoiceGenerator.php # InvoiceGeneratorInterface impl
│ │ ├── SubscriptionPeriodRefresher.php # Sync periods from Stripe
│ │ └── PlatformManagedProviderIdResolver.php
│ ├── Strategies/
│ │ └── PlatformManagedInvoicingStrategy.php
│ └── Webhooks/
│ ├── WebhookDispatcher.php # Routes events to handlers
│ └── Handlers/
│ ├── CheckoutSessionCompletedHandler.php
│ ├── SubscriptionCreatedHandler.php
│ ├── SubscriptionUpdatedHandler.php
│ ├── SubscriptionDeletedHandler.php
│ ├── InvoiceCreatedHandler.php
│ ├── InvoiceFinalizedHandler.php
│ ├── InvoicePaidHandler.php
│ └── InvoicePaymentFailedHandler.php
├── Push/
│ └── Providers/
│ └── Minishlink/
│ └── MinishWebPushGateway.php # WebPushGatewayInterface impl
├── Team/
│ └── Services/
│ └── InvitationNotifier.php # InvitationNotifierInterface impl
└── Usage/
└── Providers/
└── Stripe/
└── StripeUsageReporter.php # UsageReporterInterface impl
Providers
A Provider is a concrete implementation of a Domain Contract. It wraps an external SDK and translates between the SDK's API and your Domain's types.
Example: StripePaymentGateway
The StripePaymentGateway implements PaymentGatewayInterface. Here is its constructor and ensureCustomer method:
final class StripePaymentGateway implements PaymentGatewayInterface
{
public function __construct(
private readonly StripeClient $client,
private readonly StripeConfig $config,
) {}
/**
* Create or retrieve a Stripe customer for the tenant.
*/
public function ensureCustomer(Tenant $tenant): string
{
if ($tenant->stripe_customer_id !== null) {
$this->syncAddressToCustomer($tenant);
return (string) $tenant->stripe_customer_id;
}
$customerData = $this->buildCustomerData($tenant);
$params = [
'email' => $customerData->email,
'name' => $customerData->name,
'metadata' => $customerData->metadata,
];
if ($customerData->address !== null) {
$params['address'] = $customerData->address;
}
$customer = $this->client->customers()->create($params);
// Conditional update to handle race condition
$updated = Tenant::where('id', $tenant->id)
->whereNull('stripe_customer_id')
->update(['stripe_customer_id' => $customer->id]);
if ($updated === 0) {
// Another process already set the customer ID — reload and use that
$tenant->refresh();
return (string) $tenant->stripe_customer_id;
}
$tenant->stripe_customer_id = $customer->id;
return $customer->id;
}
// Additional methods: createCheckoutSession(), cancelSubscription(),
// resumeSubscription(), updateSubscription(), retrieveSubscription(),
// listPaymentMethods(), getBillingPortalUrl()
}
Key points:
implements PaymentGatewayInterface— fulfills the Domain Contract- Depends on
StripeClientandStripeConfig— Infrastructure-specific dependencies - Returns strings and Domain DTOs (
CheckoutSession), not Stripe SDK objects - No business logic — only technical adaptation (race condition handling, SDK calls)
- Race condition protection — the
whereNullconditional update prevents duplicate Stripe customers when concurrent requests hit the endpoint
The full class contains methods for checkout sessions, subscription management, payment methods, and billing portal URLs. Each follows the same pattern: receive domain types, call Stripe, return domain types.
Mappers
Mappers transform external SDK responses into Domain DTOs. They isolate the shape of third-party data from your business layer.
Example: TaxBreakdownMapper
/**
* Maps Stripe invoice total_tax_amounts to TaxRecordDraft[].
*
* Pure mapper: no side effects, no config awareness.
* Callers are responsible for checking StripeConfig::$taxEnabled.
*/
final class TaxBreakdownMapper
{
/**
* @return TaxRecordDraft[]
*/
public function map(StripeObject $stripeInvoice): array
{
$taxAmounts = $stripeInvoice->total_tax_amounts ?? [];
if (empty($taxAmounts)) {
return [];
}
$invoiceSubtotalCents = (int) ($stripeInvoice->subtotal ?? 0);
$records = [];
foreach ($taxAmounts as $taxAmount) {
$taxRateId = $taxAmount->tax_rate ?? null;
$amountCents = (int) ($taxAmount->amount ?? 0);
// Per-item taxable_amount takes precedence; fallback to invoice subtotal
$taxableAmountCents = isset($taxAmount->taxable_amount)
? (int) $taxAmount->taxable_amount
: $invoiceSubtotalCents;
// Resolve rate details from the tax_rate object if expanded
$jurisdiction = 'unknown';
$rate = 0.0;
$taxType = 'sales_tax';
if ($taxRateId instanceof StripeObject) {
$jurisdiction = $taxRateId->jurisdiction ?? $taxRateId->country ?? 'unknown';
$rate = (float) ($taxRateId->percentage ?? 0.0) / 100;
$taxType = $taxRateId->tax_type ?? 'sales_tax';
}
$records[] = new TaxRecordDraft(
taxType: $taxType,
jurisdiction: $jurisdiction,
rate: $rate,
taxableAmountCents: $taxableAmountCents,
taxAmountCents: $amountCents,
stripeCalculationId: null,
);
}
return $records;
}
}
Key points:
- Input: Stripe's
StripeObject(raw external data) - Output: array of
TaxRecordDraft(Domain DTO) - Pure transformation — no side effects, no database calls, no config awareness
- The caller (a webhook handler or sync service) decides whether to invoke the mapper
When to Create a Mapper
| Scenario | Create a mapper? |
|---|---|
| External API returns complex nested data | Yes |
| Simple 1:1 field mapping (2-3 fields) | Optional — can map inline in the Provider |
| Multiple providers return different shapes for the same concept | Yes — one mapper per provider |
The Integration Pattern
Every external integration follows the same pattern:
Action
→ Domain Contract (interface)
→ Infrastructure Provider (implements interface)
→ Mapper (transforms external response)
→ Domain Transfer DTO (provider-agnostic result)
Rules:
- Actions depend on interfaces, never on providers directly
- Providers implement interfaces and call the external SDK
- Mappers isolate external payloads from domain types
- No SDK calls outside Infrastructure — if you see a Stripe import in an Action, it is a bug
How to Add a New Provider
Suppose you want to add Braintree as an alternative payment provider. Here is the step-by-step process.
Step 1: Verify the Domain Contract Exists
The PaymentGatewayInterface already defines the operations you need. If your new provider requires operations not in the contract, extend the interface first.
Step 2: Create the Provider
Infrastructure/Billing/Providers/Braintree/BraintreePaymentGateway.php
final class BraintreePaymentGateway implements PaymentGatewayInterface
{
public function __construct(
private readonly BraintreeClient $client,
) {}
public function ensureCustomer(Tenant $tenant): string
{
// Braintree-specific customer creation logic
}
public function createCheckoutSession(CreateCheckoutData $data): CheckoutSession
{
// Braintree-specific checkout logic
// Returns the same CheckoutSession DTO as the Stripe implementation
}
// ... implement all interface methods
}
Step 3: Create Mappers (If Needed)
Infrastructure/Billing/Mappers/BraintreeSubscriptionMapper.php
If Braintree returns subscription data in a different shape than Stripe, create a mapper to normalize it into Domain DTOs.
Step 4: Register in the Service Provider
Update BillingServiceProvider to bind the contract to your new implementation:
// Change this line:
$this->app->bind(PaymentGatewayInterface::class, StripePaymentGateway::class);
// To this:
$this->app->bind(PaymentGatewayInterface::class, BraintreePaymentGateway::class);
Step 5: Test
Your existing Action tests should still pass — they mock PaymentGatewayInterface, not the implementation. Write new integration tests for the Braintree provider itself.
Strategy Pattern for Billing Mode
The billing system uses the strategy pattern to support different billing modes. This enables future extensibility (e.g., Merchant of Record support) without modifying existing code.
How It Works
The strategy resolution follows a chain: configuration determines the billing mode, the resolver maps the mode to a strategy implementation.
BillingServiceProvider
├── InvoicingStrategyInterface
│ └── InvoicingStrategyResolver::resolve()
│ └── ['stripe_managed' => PlatformManagedInvoicingStrategy,
│ 'platform_managed' => PlatformManagedInvoicingStrategy]
├── ProviderIdResolverInterface
│ └── ProviderIdResolverResolver::resolve()
│ └── ['stripe_managed' => PlatformManagedProviderIdResolver,
│ 'platform_managed' => PlatformManagedProviderIdResolver]
└── InvoiceQueryInterface
└── InvoiceQueryResolver::resolve()
└── ['stripe_managed' => StripeInvoiceQuery,
'platform_managed' => LocalInvoiceQuery]
The Strategy Interface
interface InvoicingStrategyInterface
{
/**
* Create an invoice from a draft.
*/
public function createInvoice(InvoiceDraft $draft): Invoice;
/**
* Finalize a draft invoice (mark as open).
*/
public function finalizeInvoice(Invoice $invoice): Invoice;
/**
* Void an invoice with a reason.
*/
public function voidInvoice(Invoice $invoice, string $reason): Invoice;
/**
* Generate PDF for an invoice.
*
* @return string Path to the generated PDF file
*/
public function generateInvoicePdf(Invoice $invoice): string;
}
The Strategy Resolver
final class InvoicingStrategyResolver
{
/**
* @param array<string, InvoicingStrategyInterface> $strategies
*/
public function __construct(
private readonly BillingModeResolver $billingModeResolver,
private readonly array $strategies,
) {}
/**
* Resolve the invoicing strategy for the current billing mode.
*
* @throws UnsupportedBillingModeException
*/
public function resolve(): InvoicingStrategyInterface
{
$mode = $this->billingModeResolver->currentOrFail();
$modeValue = $mode->value;
if (! isset($this->strategies[$modeValue])) {
throw UnsupportedBillingModeException::forMode($mode);
}
return $this->strategies[$modeValue];
}
}
The resolver receives a map of billing mode → strategy from the Service Provider. It reads the current mode from configuration and returns the matching strategy. If no strategy exists for the mode, it throws.
To Add MoR Support
- Create
MorManagedInvoicingStrategy implements InvoicingStrategyInterface - Create
MorManagedProviderIdResolver implements ProviderIdResolverInterface - Register them in
BillingServiceProviderstrategy arrays - Update
BillingMode::isSupported()to returntrueforMorManaged
No existing code changes. The strategy pattern means new modes are additive.
Service Provider Bindings
All contract-to-implementation bindings live in dedicated Service Providers. The binding is the single place where you control which implementation is active.
Here is the core of BillingServiceProvider:
final class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
// Stripe infrastructure (Octane-safe with explicit wiring)
$this->app->scoped(StripeConfig::class, fn () => StripeConfig::fromConfig());
$this->app->scoped(StripeClient::class, fn ($app) => new StripeClient(
$app->make(StripeConfig::class)
));
// Domain contract bindings
$this->app->bind(BillingCalculatorInterface::class, InternalBillingCalculator::class);
$this->app->bind(TaxProviderInterface::class, StripeTaxProvider::class);
$this->app->bind(InvoiceGeneratorInterface::class, InternalInvoiceGenerator::class);
$this->app->bind(PaymentGatewayInterface::class, StripePaymentGateway::class);
$this->app->bind(UsageReporterInterface::class, StripeUsageReporter::class);
$this->app->bind(ReconciliationServiceInterface::class, ReconciliationService::class);
$this->app->bind(SubscriptionRefresherInterface::class, SubscriptionPeriodRefresher::class);
// Strategy-based bindings (resolved via billing mode)
$this->app->bind(
InvoicingStrategyInterface::class,
fn ($app) => $app->make(InvoicingStrategyResolver::class)->resolve()
);
$this->app->bind(
ProviderIdResolverInterface::class,
fn ($app) => $app->make(ProviderIdResolverResolver::class)->resolve()
);
$this->app->bind(
InvoiceQueryInterface::class,
fn ($app) => $app->make(InvoiceQueryResolver::class)->resolve()
);
}
}
Binding Summary
| Domain Contract | Implementation | Scope |
|---|---|---|
PaymentGatewayInterface | StripePaymentGateway | Direct binding |
BillingCalculatorInterface | InternalBillingCalculator | Direct binding |
TaxProviderInterface | StripeTaxProvider | Direct binding |
InvoiceGeneratorInterface | InternalInvoiceGenerator | Direct binding |
UsageReporterInterface | StripeUsageReporter | Direct binding |
InvoicingStrategyInterface | Via InvoicingStrategyResolver | Strategy pattern |
ProviderIdResolverInterface | Via ProviderIdResolverResolver | Strategy pattern |
InvoiceQueryInterface | Via InvoiceQueryResolver | Strategy pattern |
OAuthProvider | SocialiteOAuthProvider | AppServiceProvider |
InvitationNotifierInterface | InvitationNotifier | AppServiceProvider |
WebPushGatewayInterface | MinishWebPushGateway | AppServiceProvider |
Direct bindings are a one-line swap. Strategy bindings route through a resolver based on the configured billing mode.
scoped() bindings, not singleton(). This ensures Octane safety — each request gets a fresh Stripe client with the correct API key, even in long-running processes.Webhooks
Stripe webhook events are routed by a WebhookDispatcher to dedicated handler classes:
Infrastructure/Billing/Webhooks/
├── WebhookDispatcher.php # Routes events to handlers
└── Handlers/
├── CheckoutSessionCompletedHandler.php
├── SubscriptionCreatedHandler.php
├── SubscriptionUpdatedHandler.php
├── SubscriptionDeletedHandler.php
├── InvoiceCreatedHandler.php
├── InvoiceFinalizedHandler.php
├── InvoicePaidHandler.php
└── InvoicePaymentFailedHandler.php
Each Stripe event type has a dedicated handler. The WebhookDispatcher verifies the webhook signature, identifies the event type, and delegates to the appropriate handler. Handlers translate external events into internal state changes — creating subscriptions, updating invoice statuses, or recording payment failures. This is Infrastructure, not Application — it adapts external events to your internal models.
Webhook handling is covered in detail in Webhooks.
What's Next
- Domain Layer — The contracts that Infrastructure implements
- Application Layer — The use cases that depend on these contracts