Skip to content
SaaS4Builders
Backend

Infrastructure Layer

External integrations, provider implementations, mappers, strategy patterns, and service provider bindings.

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:

backend/app/Infrastructure/Billing/Providers/Stripe/StripePaymentGateway.php
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 StripeClient and StripeConfig — 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 whereNull conditional 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

backend/app/Infrastructure/Billing/Mappers/TaxBreakdownMapper.php
/**
 * 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

ScenarioCreate a mapper?
External API returns complex nested dataYes
Simple 1:1 field mapping (2-3 fields)Optional — can map inline in the Provider
Multiple providers return different shapes for the same conceptYes — 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:

backend/app/Providers/BillingServiceProvider.php
// 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.

To swap an implementation, you change a single line in the Service Provider. No Actions, no Controllers, no Queries need to change — they depend on the interface, not the implementation.

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

backend/app/Domain/Billing/Contracts/InvoicingStrategyInterface.php
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

backend/app/Domain/Billing/Services/InvoicingStrategyResolver.php
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

  1. Create MorManagedInvoicingStrategy implements InvoicingStrategyInterface
  2. Create MorManagedProviderIdResolver implements ProviderIdResolverInterface
  3. Register them in BillingServiceProvider strategy arrays
  4. Update BillingMode::isSupported() to return true for MorManaged

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:

backend/app/Providers/BillingServiceProvider.php
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 ContractImplementationScope
PaymentGatewayInterfaceStripePaymentGatewayDirect binding
BillingCalculatorInterfaceInternalBillingCalculatorDirect binding
TaxProviderInterfaceStripeTaxProviderDirect binding
InvoiceGeneratorInterfaceInternalInvoiceGeneratorDirect binding
UsageReporterInterfaceStripeUsageReporterDirect binding
InvoicingStrategyInterfaceVia InvoicingStrategyResolverStrategy pattern
ProviderIdResolverInterfaceVia ProviderIdResolverResolverStrategy pattern
InvoiceQueryInterfaceVia InvoiceQueryResolverStrategy pattern
OAuthProviderSocialiteOAuthProviderAppServiceProvider
InvitationNotifierInterfaceInvitationNotifierAppServiceProvider
WebPushGatewayInterfaceMinishWebPushGatewayAppServiceProvider

Direct bindings are a one-line swap. Strategy bindings route through a resolver based on the configured billing mode.

Stripe infrastructure is registered as 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