
How I Handle Third-Party Integrations in Laravel (Without Breaking Everything)
Third-party integrations rarely fail in development. They fail on Friday night, when the provider returns something nobody documented, your queue starts piling up, and a customer is staring at a payment screen wondering if they were charged twice.
The truth most engineers learn the hard way: when you integrate with an external system, your application is no longer fully yours. You inherit their downtime, their rate limits, their schema changes, and their bad days. You can write the cleanest Laravel code in the world, and a thirty-second outage at Stripe will still wake you up.
This is the seventh post in a Laravel series. I have written about the mistakes I stopped making, the structure I use by default, performance work in production, API design that survives change, authentication and authorization, and which abstractions earn their keep. This one is about the layer where your code stops being yours: the moment you call a system you do not control.
This is where many Laravel apps stop failing because of code and start failing because of assumptions.
This post is about how I now design Laravel third party integrations to survive that reality.
Table of Contents
The Real Problem
People talk about integrations like they are API calls. They are not. An API call is the easy part - Http::post() and you are done. The hard part is everything that lives around it.
Every real integration drags in:
- Latency you cannot predict
- Retries that can multiply load on a degraded provider
- Partial failures (charge succeeded, response timed out)
- Inconsistent payload shapes between sandbox and production
- Provider downtime you do not control
- Duplicate webhook deliveries
- Eventual consistency you did not sign up for
The first request is trivial. Surviving the second, third, and ten-thousandth - when the network is flaky and the provider is half-broken - is the actual engineering problem.
Where Integrations Go Wrong
Looking back at integrations I have inherited (and a few I wrote and regret), the same mistakes appear:
- Calling third-party APIs directly from controllers
- No timeouts, so a slow provider hangs every PHP-FPM worker
- No retry strategy, or worse - infinite retries hammering a degraded provider
- No structured logging, so failures are invisible until a user complains
- Treating webhooks as if they arrive once, in order, on time
- Trusting the payload - including IDs, amounts, and statuses - without verifying anything against the provider
- Using a provider SDK directly across half the codebase, making swaps or upgrades a multi-week project
Most outages I have debugged in production were not caused by exotic bugs. They were caused by one of those.
How I Structure Integrations Now
The pattern I keep coming back to is intentionally boring. Boring is what survives at 2am.
A dedicated integration layer
Every external system gets its own service class. Controllers, jobs, and listeners talk to that - never to the SDK. A Stripe integration Laravel codebase should have exactly one place that knows the word "Stripe."
class StripeGateway
{
public function __construct(private StripeClient $stripe) {}
public function charge(Order $order): ChargeResult
{
$intent = $this->stripe->paymentIntents->create([
'amount' => $order->total_cents,
'currency' => $order->currency,
'customer' => $order->customer->stripe_id,
'metadata' => ['order_id' => $order->id],
], [
'idempotency_key' => "order_{$order->id}_charge",
]);
return ChargeResult::fromIntent($intent);
}
}The win is not elegance. It is that the rest of the app does not know or care that Stripe exists. Swapping providers, mocking in tests, or adding a circuit breaker happens in one file.
Queues for anything that can wait
Synchronous calls to third parties are the fastest way to take down your app. Webhooks, emails, syncs, status checks - all of it goes on a queue.
ProcessStripeWebhook::dispatch($event)->onQueue('webhooks');The HTTP layer's job is to validate, persist, and acknowledge. The queue's job is to actually do the work, with retries and isolation from the request cycle.
Explicit timeouts and bounded retries
Defaults are dangerous. Laravel's HTTP client will happily wait far longer than your users will.
Http::timeout(5)
->connectTimeout(2)
->retry(3, 200, throw: false)
->withHeaders(['Idempotency-Key' => $key])
->post($url, $payload);Three retries with backoff, capped. No infinite loops. Failures escalate to a dead-letter queue I can inspect - not a silent log line nobody reads.
Idempotency, everywhere
Idempotency keys are not optional. The same charge request must produce the same outcome whether it runs once or five times.
$key = "order:{$order->id}:charge:v1"; For inbound webhooks, I store the provider's event ID in a processed_webhooks table with a unique constraint. Replays are absorbed silently.
Logging and tracing built in
Every outbound request and every webhook gets logged with a correlation ID, status, latency, and a redacted payload. When something breaks at 3am, I want to grep one ID and see the full story - not piece it together from five services.
Webhooks Deserve Their Own Section
Webhook handling Laravel is the part of every integration that gets underestimated. Webhooks look like simple HTTP requests. They are not.
Webhooks are eventually consistent messages from a system you do not control. They will:
- Arrive late (sometimes minutes, sometimes hours)
- Arrive duplicated
- Arrive out of order -
payment.succeededbeforepayment.createdis real and I have seen it - Disappear and re-appear after a provider's incident
My webhook checklist is short and non-negotiable:
- Verify the signature first. Reject anything that does not match before touching the database.
- Persist the raw event immediately, keyed by provider event ID with a unique index.
- Acknowledge with 200 fast - under a second. The provider is timing you.
- Process asynchronously in a queued job that is safe to run multiple times.
- Reconcile, do not trust. For anything financial, fetch the canonical state from the provider's API before acting on it.
public function handle(Request $request)
{
$event = $this->stripe->verifyAndParse(
$request->getContent(),
$request->header('Stripe-Signature')
);
WebhookEvent::firstOrCreate(
['provider' => 'stripe', 'event_id' => $event->id],
['payload' => $event->toArray()]
);
ProcessStripeWebhook::dispatch($event->id);
return response()->noContent();
}The controller does almost nothing. That is the point.
A Real Example That Cost Us
A while back, a Stripe integration I worked on processed charge.succeeded directly inside the webhook controller. Order marked paid, invoice generated, fulfillment triggered - all synchronously, all inside a single request.
It worked. Until Stripe had a bad afternoon and redelivered events. Some customers got two invoices. A handful got two shipments. Support spent days untangling it.
The fix was not dramatic:
- Move processing into a queued job
- Add a unique index on
(provider, event_id) - Fetch the charge from Stripe inside the job instead of trusting the payload
- Make the order state transition idempotent
Duplicate processing dropped to zero. Webhook controller p95 went from ~600ms to under 80ms because we stopped doing real work in the request cycle. Provider retries stopped causing incidents - they became a non-event.
That single refactor changed how I treat every Laravel API integrations project since.
What I Avoid Now
- Business logic inside webhook controllers
- Direct SDK calls scattered across controllers, models, and Blade views
- Treating a 200 response as proof the operation succeeded - confirm state on the provider when it matters
- Doing heavy work synchronously in the request that triggered it
- Retrying without limits, jitter, or a circuit breaker
- Logging only on failure (you will want the success traces when reconciling)
Key Principles I Follow
- External systems are unreliable by default. Design for it.
- Every integration needs an explicit failure path, not a hopeful one.
- Webhooks must be idempotent. Always.
- Provider-specific code lives in one place.
- Queue everything that can wait - and most things can.
- Reconcile, do not trust. Especially for money.
Closing Thoughts
The success path is easy. Anyone can write the happy path on a Tuesday afternoon with good wifi.
Good Laravel third party integrations are designed around the failure path - the timeouts, the retries, the duplicate webhooks, the half-completed transactions. That is the part that decides whether your phone rings on Friday night.
Most integrations work perfectly on the happy path. Production failures happen everywhere else.
If you build the failure path first, the success path takes care of itself.
What is the worst third-party integration issue you have faced in production?