Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 053 / 100

Webhooks in Laravel: a PHP & Laravel guide for developers

Receive events from Stripe, Shopify and others.

Expona Admin9 min read

Webhooks in Laravel: a PHP & Laravel guide for developers

Building a robust integration with external payment processors, marketplace platforms, or third-party services often hinges on one thing: webhooks. Whether you're processing Stripe charges, syncing Shopify orders, or consuming events from any HTTP-based API, handling webhooks correctly separates production systems from those that leak money and data. Webhooks in Laravel: a PHP & Laravel guide for developers covers the practical patterns we've learned scaling e-commerce and SaaS applications that receive hundreds of webhook events per minute.

Why webhooks matter

A webhook is an HTTP callback—a way for an external service to push events to your application rather than your application repeatedly polling for changes. This is critical for real-time integrations. When a customer pays via Stripe, you don't want to wait five minutes to check the payment status. Instead, Stripe sends you an event immediately, and you process it. But webhook handling is deceptively tricky. Network failures, duplicate deliveries, timing issues, and signature verification all demand careful attention.

Let's walk through the patterns we use in production to handle webhooks safely and reliably in Laravel.

Verify signatures before processing anything

The first rule: never trust an incoming webhook without verifying its source. Any external service worth integrating with will sign its payloads using a secret key that only you and the service know. If the signature doesn't match, reject the request immediately.

Stripe uses HMAC-SHA256 signatures. Shopify does too. Most modern APIs follow this pattern. Here's how we implement it in Laravel:

php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;

class StripeWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $payload = $request->getContent();
        $sig_header = $request->header('Stripe-Signature');
        $secret = config('services.stripe.webhook_secret');

        try {
            $event = \Stripe\Webhook::constructEvent(
                $payload,
                $sig_header,
                $secret
            );
        } catch (\UnhandledMatchException $e) {
            return response('Invalid signature', Response::HTTP_UNAUTHORIZED);
        }

        // Process the verified event
        return response('Webhook received', Response::HTTP_OK);
    }
}

Never compute signatures on the JSON-decoded payload. Always use the raw request body. Stripe's SDK handles this correctly—the raw body is what was signed, and any JSON parsing changes the bytes, breaking signature verification.

For services without built-in webhook libraries, implement verification yourself:

php
private function verifySignature(Request $request): bool
{
    $payload = $request->getContent();
    $signature = $request->header('X-Webhook-Signature');
    $secret = config('services.myapi.webhook_secret');

    $computed = hash_hmac('sha256', $payload, $secret, false);
    
    return hash_equals($computed, $signature);
}

The hash_equals function performs a timing-safe comparison—it takes the same amount of time regardless of where the strings differ, preventing timing attacks.

Respond quickly, process in a queued job

Webhook endpoints must respond quickly. If your server takes three seconds to process a payment confirmation, the service sending the webhook may time out and retry, leading to duplicate processing. The solution is simple: verify the signature, acknowledge receipt immediately, then do the heavy work asynchronously.

Here's the pattern we use:

php
<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessStripeWebhook;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class StripeWebhookController extends Controller
{
    public function handle(Request $request)
    {
        // Verify signature (as shown above)
        $event = $this->verifyAndParse($request);
        
        if (!$event) {
            return response('Invalid signature', Response::HTTP_UNAUTHORIZED);
        }

        // Dispatch job and return immediately
        ProcessStripeWebhook::dispatch($event);

        return response('Webhook received', Response::HTTP_OK);
    }
}

Then the job handles the actual business logic:

php
<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class ProcessStripeWebhook implements ShouldQueue
{
    use Dispatchable, Queueable;

    public function __construct(private array $event)
    {
    }

    public function handle()
    {
        $eventType = $this->event['type'];

        match ($eventType) {
            'payment_intent.succeeded' => $this->handlePaymentSucceeded(),
            'payment_intent.payment_failed' => $this->handlePaymentFailed(),
            'charge.refunded' => $this->handleRefund(),
            default => null,
        };
    }

    private function handlePaymentSucceeded()
    {
        $charge = $this->event['data']['object'];
        // Update order status, send confirmation email, etc.
    }
}

This approach keeps your webhook endpoint fast. Queue jobs can retry on failure, handle backpressure gracefully, and be monitored independently. We typically set these jobs to retry up to 5 times with exponential backoff.

Store event IDs for idempotency

Webhooks are delivered at least once, not exactly once. A network hiccup, a timeout, or a crash can cause the same event to be delivered multiple times. If you process it twice without checking, you might charge a customer twice, create duplicate orders, or send two confirmation emails.

The solution is idempotency: storing event IDs and checking before processing. Here's the schema and logic:

php
// In a migration
Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('event_id')->unique();
    $table->string('event_type');
    $table->string('source'); // 'stripe', 'shopify', etc.
    $table->json('payload');
    $table->string('status')->default('pending'); // pending, processed, failed
    $table->text('error')->nullable();
    $table->timestamps();
});

In your webhook handler, store the event first, then process it:

php
public function handle(Request $request)
{
    $event = $this->verifyAndParse($request);
    
    // Check if we've already processed this event
    $stored = WebhookEvent::firstOrCreate(
        ['event_id' => $event['id'], 'source' => 'stripe'],
        [
            'event_type' => $event['type'],
            'payload' => $event,
            'status' => 'pending',
        ]
    );

    // If this is a duplicate, return OK without reprocessing
    if ($stored->wasRecentlyCreated === false) {
        return response('Webhook received', Response::HTTP_OK);
    }

    // New event, dispatch job to process it
    ProcessStripeWebhook::dispatch($stored->id);

    return response('Webhook received', Response::HTTP_OK);
}

Then update the status in your job:

php
public function handle()
{
    $webhookEvent = WebhookEvent::find($this->eventId);

    try {
        // Process the event
        match ($webhookEvent->event_type) {
            'payment_intent.succeeded' => $this->handlePayment($webhookEvent),
            // ...
        };

        $webhookEvent->update(['status' => 'processed']);
    } catch (\Exception $e) {
        $webhookEvent->update([
            'status' => 'failed',
            'error' => $e->getMessage(),
        ]);
        throw $e; // Let queue retry
    }
}

This ensures that even if the same event arrives ten times, you only process it once.

Log payloads for debugging

Webhooks are a black box until something goes wrong. You need visibility into what data arrived, what you processed, and what happened. We store the full payload in the webhook_events table (shown above), but you also want structured logging.

Add logging to your webhook controller and job:

php
public function handle(Request $request)
{
    $event = $this->verifyAndParse($request);
    
    \Log::info('Webhook received', [
        'source' => 'stripe',
        'event_id' => $event['id'],
        'event_type' => $event['type'],
        'timestamp' => $event['created'],
    ]);

    $stored = WebhookEvent::firstOrCreate(
        ['event_id' => $event['id'], 'source' => 'stripe'],
        [
            'event_type' => $event['type'],
            'payload' => $event,
            'status' => 'pending',
        ]
    );

    if (!$stored->wasRecentlyCreated) {
        \Log::info('Duplicate webhook', ['event_id' => $event['id']]);
        return response('Webhook received', Response::HTTP_OK);
    }

    ProcessStripeWebhook::dispatch($stored->id);

    return response('Webhook received', Response::HTTP_OK);
}

In your job, log processing steps and outcomes:

php
public function handle()
{
    $webhookEvent = WebhookEvent::find($this->eventId);
    $payload = $webhookEvent->payload;

    try {
        \Log::info('Processing webhook', [
            'event_id' => $webhookEvent->event_id,
            'event_type' => $webhookEvent->event_type,
        ]);

        match ($webhookEvent->event_type) {
            'payment_intent.succeeded' => $this->handlePayment($payload),
        };

        \Log::info('Webhook processed successfully', [
            'event_id' => $webhookEvent->event_id,
        ]);

        $webhookEvent->update(['status' => 'processed']);
    } catch (\Exception $e) {
        \Log::error('Webhook processing failed', [
            'event_id' => $webhookEvent->event_id,
            'error' => $e->getMessage(),
            'trace' => $e->getTraceAsString(),
        ]);

        $webhookEvent->update([
            'status' => 'failed',
            'error' => $e->getMessage(),
        ]);

        throw $e;
    }
}

Configure your logger to send critical webhook errors to Slack or an alerting service. When a payment confirmation fails to process, you want to know immediately, not when a customer complains.

Route configuration and middleware

Register your webhook routes outside of authentication middleware. Webhooks come from external services that don't have your session cookies or authentication tokens:

php
// routes/web.php
Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle'])
    ->name('stripe.webhook');

Route::post('/webhooks/shopify', [ShopifyWebhookController::class, 'handle'])
    ->name('shopify.webhook');

If you need additional security (IP whitelisting), apply it at the middleware level:

php
// app/Http/Middleware/VerifyWebhookIp.php
public function handle(Request $request, Closure $next)
{
    $allowedIps = config('webhooks.allowed_ips');
    
    if (!in_array($request->ip(), $allowedIps)) {
        return response('Forbidden', 403);
    }

    return $next($request);
}

Apply it to webhook routes in your route file or in RouteServiceProvider.

Common mistakes

Processing without verifying signatures: We've seen production incidents where developers skipped signature verification to "move faster." This exposed the application to malicious payloads. Always verify.

Holding open database connections during webhook processing: If you process the webhook directly in the controller (not in a queued job), you tie up a database connection for the duration of the work. This exhausts the connection pool and causes cascading failures. Always use jobs.

Not handling duplicate events: We once saw an order processed three times because a payment webhook was delivered three times and the code had no idempotency check. Three times. Store event IDs.

Logging the full payload to stdout: If your payload contains sensitive data (credit card tokens, PII), logging to stdout or standard files can leak it. Use structured logging with sensitive fields masked, and store full payloads in a database with restricted access.

Timing out webhook requests: If your Laravel app takes too long to respond, the webhook service retries. If every retry also takes too long, you get exponential retry storms. Keep controller logic under 100ms; do everything else in jobs.

Key takeaways

  • Verify webhook signatures using HMAC-SHA256 before processing anything; never skip this step.
  • Respond to the webhook immediately (under 200ms); dispatch a queued job to do the actual work.
  • Store event IDs in a database and check them before processing to handle duplicates safely.
  • Log event receipt, processing steps, and errors to a structured logger; include event IDs for tracing.
  • Keep full payloads in the webhook_events table for auditing and debugging production issues.
  • Set queue jobs to retry on failure with exponential backoff; monitor failed jobs closely.
  • Register webhook routes outside authentication middleware; the external service can't authenticate with your session.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles

Webhooks in Laravel | PHP & Laravel Guide | Expona