How to Handle Webhooks in Laravel

Creating Webhook Routes

In Laravel, add webhook routes in routes/api.php — API routes automatically skip CSRF verification:

php
// routes/api.php
use App\Http\Controllers\WebhookController;

Route::post('/webhooks/stripe', [WebhookController::class, 'stripe']); Route::post('/webhooks/github', [WebhookController::class, 'github']); ```

If you prefer to use routes/web.php, exclude the webhook routes from CSRF verification in app/Http/Middleware/VerifyCsrfToken.php:

php
protected $except = [
    'webhooks/*',
];

Create the controller:

php
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class WebhookController extends Controller { public function stripe(Request $request) { $payload = $request->getContent(); $event = json_decode($payload, true);

match($event['type']) { 'payment_intent.succeeded' => $this->handlePayment($event['data']['object']), 'customer.subscription.deleted' => $this->handleCancellation($event['data']['object']), default => logger()->info("Unhandled event: {$event['type']}"), };

return response()->json(['received' => true]); } } ```

Signature Verification

Use $request->getContent() to access the raw body for signature verification:

php
public function stripe(Request $request)
{
    $payload = $request->getContent();
    $sigHeader = $request->header('Stripe-Signature');
    $secret = config('services.stripe.webhook_secret');

try { $event = \Stripe\Webhook::constructEvent( $payload, $sigHeader, $secret ); } catch (\Stripe\Exception\SignatureVerificationException $e) { return response('Invalid signature', 400); }

// Process the verified event $this->processEvent($event);

return response()->json(['received' => true]); } ```

For custom HMAC verification (non-Stripe providers):

php
private function verifySignature(Request $request, string $secret): bool
{
    $signature = $request->header('X-Webhook-Signature');
    $payload = $request->getContent();

$computed = hash_hmac('sha256', $payload, $secret);

return hash_equals($computed, $signature); } ```

PHP's hash_equals() function performs timing-safe comparison, similar to crypto.timingSafeEqual in Node.js.

Queue-Based Processing

Laravel's queue system is ideal for async webhook processing:

php
// app/Jobs/ProcessWebhookEvent.php
namespace App\Jobs;

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

class ProcessWebhookEvent implements ShouldQueue { use Queueable;

public function __construct( private string $eventType, private array $eventData ) {}

public function handle(): void { match($this->eventType) { 'payment_intent.succeeded' => $this->handlePayment(), 'customer.subscription.deleted' => $this->handleCancellation(), default => null, }; }

private function handlePayment(): void { $order = Order::where('stripe_payment_intent_id', $this->eventData['id'])->first(); $order?->markAsPaid(); } }

// In the controller public function stripe(Request $request) { // ... signature verification ...

// Idempotency check $eventId = $event->id; if (ProcessedEvent::where('event_id', $eventId)->exists()) { return response()->json(['received' => true]); } ProcessedEvent::create(['event_id' => $eventId]);

// Dispatch to queue ProcessWebhookEvent::dispatch($event->type, $event->data->object->toArray());

return response()->json(['received' => true]); } ```

Middleware Approach

For a cleaner architecture, create middleware that handles verification and parsing:

php
// app/Http/Middleware/VerifyWebhookSignature.php
namespace App\Http\Middleware;

use Closure; use Illuminate\Http\Request;

class VerifyWebhookSignature { public function handle(Request $request, Closure $next, string $provider) { $secret = config("services.{$provider}.webhook_secret"); $signature = $request->header('X-Webhook-Signature'); $payload = $request->getContent();

$computed = hash_hmac('sha256', $payload, $secret);

if (!hash_equals($computed, $signature ?? '')) { return response('Invalid signature', 401); }

return $next($request); } } ```

Apply the middleware to specific routes:

php
Route::post('/webhooks/github', [WebhookController::class, 'github'])
    ->middleware('verify.webhook:github');

This separates the verification concern from the handler logic, keeping your controllers clean and the verification logic reusable.

Testing with ReqPour

Start Laravel's development server and ReqPour relay:

bash
php artisan serve --port=8000
# In another terminal:
npx reqpour relay --to http://localhost:8000

Write feature tests using Laravel's testing tools:

php
class WebhookTest extends TestCase
{
    public function test_processes_payment_webhook(): void
    {
        $payload = [
            'id' => 'evt_test_123',
            'type' => 'payment_intent.succeeded',
            'data' => ['object' => ['id' => 'pi_456', 'amount' => 2500]],
        ];

$response = $this->postJson('/api/webhooks/stripe', $payload);

$response->assertOk(); $this->assertDatabaseHas('orders', [ 'stripe_payment_intent_id' => 'pi_456', 'status' => 'paid', ]); } } ```

Use payloads from the ReqPour dashboard as test data to ensure your tests match real-world webhook formats.

Get started with ReqPour

Catch, inspect, and relay webhooks to localhost. Free to start, $3/mo for Pro.