How to Handle Webhooks in Laravel
Creating Webhook Routes
In Laravel, add webhook routes in routes/api.php — API routes automatically skip CSRF verification:
// 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:
protected $except = [
'webhooks/*',
];Create the controller:
// 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:
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):
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:
// 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:
// 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:
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:
php artisan serve --port=8000
# In another terminal:
npx reqpour relay --to http://localhost:8000Write feature tests using Laravel's testing tools:
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.
Related
Get started with ReqPour
Catch, inspect, and relay webhooks to localhost. Free to start, $3/mo for Pro.