V Volkanic
Backend

Building reliable third-party integrations in Laravel

Practical patterns for integrating external APIs without letting their failures cascade into yours: adapters, circuit breakers, and retry strategies.

8 Min. Lesezeit
LaravelPHPAPIsArchitecture

Third-party API integrations are where a lot of backend complexity lives. The external service will go down. It will return unexpected formats. It will rate-limit you at the worst possible moment.

Here’s how I structure integrations in Laravel to contain that blast radius.

The adapter pattern as the core boundary

Every external service gets its own adapter class. The adapter does one job: translate between the external world and your internal domain.

interface TourProviderAdapter
{
    public function getAvailability(AvailabilityQuery $query): AvailabilityResult;
    public function createReservation(ReservationIntent $intent): Reservation;
}

The concrete implementation (AcmeTourAdapter, ExpediaTourAdapter) handles authentication, serialization, and provider-specific quirks. The application service never touches the HTTP layer.

This boundary is not optional. When Acme Tours changes their auth scheme, you change one class.

Handling failures gracefully

External APIs fail. The question is whether their failure becomes your failure.

Timeouts. Set explicit connection and read timeouts. A slow provider should not tie up your PHP-FPM workers.

Http::timeout(5)
    ->connectTimeout(2)
    ->get($url, $params);

Retries with backoff. Transient failures (network blips, 503s) often resolve. Retry with exponential backoff and jitter, but cap the attempts.

Circuit breakers. If a provider fails repeatedly, stop calling it and fail fast. This protects your system from cascade effects and gives the provider time to recover.

Normalizing the response

Provider responses are raw external data. Map them to your internal types immediately at the adapter boundary.

private function mapAvailability(array $raw): AvailabilityResult
{
    return new AvailabilityResult(
        slots: collect($raw['slots'])->map(fn($s) => new Slot(
            startsAt: CarbonImmutable::parse($s['start_datetime']),
            capacity: (int) $s['available_places'],
            price: Money::of($s['price_eur'], 'EUR'),
        ))->all(),
    );
}

Once you’re past the adapter, your application works with your types, not theirs.

Caching strategically

Availability data is expensive to fetch and changes frequently but not constantly. Cache with a short TTL (30–120 seconds depending on the provider’s SLA), warm on a schedule during low-traffic windows, and implement lazy refresh on cache misses with a lock to prevent cache stampedes.

Logging and observability

Log every outbound request and response at debug level. Log failures at error level with the full context needed to reproduce the issue. Add a correlation ID to trace a user request through your system and into the external call.

When integration bugs happen (and they will), you want the full picture without having to reproduce the issue from scratch.