Building a Robust Webhook System in Laravel
Prerequisites
Before you crack on, make sure you’ve got:
- Laravel 10+ installed and working
- A queue worker actually running (not just “I’ll set that up later”)
- Basic understanding of Laravel’s HTTP client
- At least one mass-produced energy drink within arm’s reach
- The emotional resilience to debug failed HTTP requests at 11pm
- A working internet connection (you’d be surprised)
What We’re Building
Right, let’s talk about webhooks. You know that thing where your app needs to tap another service on the shoulder and say “oi, something happened”? That’s what we’re building.
We’re putting together a proper webhook system that notifies external services when events fire in your application. We’re talking retry logic, signature verification, delivery tracking. The whole lot. Because sending a single POST request and hoping for the best isnt a strategy, its a prayer.

The Approach
- Create webhook endpoint storage
- Build event dispatching
- Implement retry with exponential backoff
- Add signature verification
- Track delivery status
Nothing revolutionary, but getting all five of these working together reliably is where the magic happens.
Step 1: Database Structure
First up, we need somewhere to store our webhook endpoints and their delivery history. Two migrations, nice and clean.
php artisan make:model WebhookEndpoint -m
php artisan make:model WebhookDelivery -m
The endpoints table holds where we’re sending things, and the deliveries table keeps a record of every attempt. Think of it like a very boring diary.
// create_webhook_endpoints_table.php
Schema::create('webhook_endpoints', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('url');
$table->string('secret');
$table->json('events');
$table->boolean('active')->default(true);
$table->timestamps();
});
// create_webhook_deliveries_table.php
Schema::create('webhook_deliveries', function (Blueprint $table) {
$table->id();
$table->foreignId('webhook_endpoint_id')->constrained()->cascadeOnDelete();
$table->string('event');
$table->json('payload');
$table->integer('response_code')->nullable();
$table->text('response_body')->nullable();
$table->integer('attempts')->default(0);
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
});
That events JSON column is doing some heavy lifting. It lets users subscribe to specific events rather than getting blasted with everything. Nobody wants that.
Step 2: Webhook Endpoint Model
The model itself is straightforward. The subscribesTo method is the interesting bit, it checks whether an endpoint cares about a given event, with wildcard support for the “give me everything” crowd.
// app/Models/WebhookEndpoint.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class WebhookEndpoint extends Model
{
protected $fillable = ['url', 'secret', 'events', 'active'];
protected $casts = [
'events' => 'array',
'active' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function deliveries(): HasMany
{
return $this->hasMany(WebhookDelivery::class);
}
public function subscribesTo(string $event): bool
{
return in_array($event, $this->events) || in_array('*', $this->events);
}
}
Step 3: Dispatch Service
This is the brain of the operation. The dispatcher finds all active endpoints that care about the event, creates a delivery record for each one, and shoves them onto the queue.
// app/Services/WebhookDispatcher.php
namespace App\Services;
use App\Jobs\SendWebhook;
use App\Models\WebhookDelivery;
use App\Models\WebhookEndpoint;
class WebhookDispatcher
{
public function dispatch(string $event, array $payload, ?int $userId = null): void
{
$query = WebhookEndpoint::where('active', true);
if ($userId) {
$query->where('user_id', $userId);
}
$query->get()
->filter(fn ($endpoint) => $endpoint->subscribesTo($event))
->each(function ($endpoint) use ($event, $payload) {
$delivery = WebhookDelivery::create([
'webhook_endpoint_id' => $endpoint->id,
'event' => $event,
'payload' => $payload,
]);
SendWebhook::dispatch($delivery);
});
}
}
Notice we’re creating the delivery record before dispatching the job. This way, even if the queue explodes, we’ve got a record of what we tried to send. Trust nothing, log everything.
Step 4: Webhook Job with Retries
Here’s where it gets properly interesting. The job handles the actual HTTP request, and this is where most webhook implementations fall flat on their face.
// app/Jobs/SendWebhook.php
namespace App\Jobs;
use App\Models\WebhookDelivery;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class SendWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 5;
public $backoff = [60, 300, 900, 3600, 7200];
public function __construct(public WebhookDelivery $delivery) {}
public function handle(): void
{
$endpoint = $this->delivery->webhookEndpoint;
$payload = $this->delivery->payload;
$signature = $this->generateSignature($payload, $endpoint->secret);
$response = Http::timeout(30)
->withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Signature' => $signature,
'X-Webhook-Event' => $this->delivery->event,
])
->post($endpoint->url, $payload);
$this->delivery->update([
'attempts' => $this->delivery->attempts + 1,
'response_code' => $response->status(),
'response_body' => substr($response->body(), 0, 1000),
'delivered_at' => $response->successful() ? now() : null,
]);
if (!$response->successful()) {
throw new \Exception("Webhook failed: {$response->status()}");
}
}
private function generateSignature(array $payload, string $secret): string
{
return hash_hmac('sha256', json_encode($payload), $secret);
}
}
See that $backoff array? That’s exponential backoff. First retry after 1 minute, then 5, then 15, then an hour, then two hours. We’re persistent, not obnoxious. The receiving server might be having a bad day. We dont need to make it worse by hammering it every 30 seconds.

Step 5: Use Events to Trigger Webhooks
Now we wire it into Laravel’s event system. This is the glue that makes the whole thing feel native rather than bolted on.
// app/Listeners/DispatchWebhooks.php
namespace App\Listeners;
use App\Events\OrderCreated;
use App\Services\WebhookDispatcher;
class DispatchWebhooks
{
public function __construct(private WebhookDispatcher $dispatcher) {}
public function handle(OrderCreated $event): void
{
$this->dispatcher->dispatch('order.created', [
'order_id' => $event->order->id,
'total' => $event->order->total,
'customer' => $event->order->customer->email,
'created_at' => $event->order->created_at->toIso8601String(),
], $event->order->user_id);
}
}
Clean separation. Your domain events fire as normal, and the listener quietly handles the webhook side of things. The rest of your application doesnt need to know or care.
Step 6: Verification for Receivers
This bit is for your consumers. You’ll want to document how they can verify that a webhook actually came from you and not some random person on the internet pretending to be you.
// Example for webhook receivers
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = 'your-webhook-secret';
$expected = hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$data = json_decode($payload, true);
// Process webhook...
The hash_equals function is crucial here. It does a timing-safe comparison, which prevents timing attacks. Using === would technically work, but it leaks information about how many characters matched before failing. Security is fun like that.
Step 7: Admin Dashboard
Finally, give yourself (and your users) a way to see what’s going on. A delivery log and a retry button. Simple, but you will be grateful for it at 2am when someone swears they never received a webhook.

// app/Http/Controllers/WebhookController.php
public function deliveries(WebhookEndpoint $endpoint)
{
return $endpoint->deliveries()
->latest()
->paginate(20)
->through(fn ($delivery) => [
'id' => $delivery->id,
'event' => $delivery->event,
'status' => $delivery->delivered_at ? 'delivered' : 'pending',
'attempts' => $delivery->attempts,
'response_code' => $delivery->response_code,
'created_at' => $delivery->created_at,
]);
}
public function retry(WebhookDelivery $delivery)
{
SendWebhook::dispatch($delivery);
return response()->json(['message' => 'Webhook queued for retry']);
}
That retry endpoint will save your life. When a customer’s server was briefly down and they missed a critical event, you can just hit retry instead of manually reconstructing the payload and firing it off with cURL like some kind of caveman.

The Result
What you end up with:
- Reliable webhook delivery with automatic retries
- Cryptographic signature verification (so nobody can spoof your webhooks)
- Full delivery history for debugging those “I never got it” conversations
- Manual retry capability for when things go sideways
What I’d Do Differently
Add webhook endpoint validation on creation. Send a test payload and require a specific response before activating the endpoint. This alone will save you a mountain of support tickets from misconfigured URLs. You know the ones, where someone puts http://localhost:3000 as their webhook URL in production and then wonders why nothing arrives.
A solid webhook system turns your API from a tool into a platform. And if you take one thing away from this, let it be the exponential backoff. Most implementations skip it, and most implementations end up getting rate-limited into oblivion because of it.