Tony Joe Dev
Posted on August 20, 2023
Web applications are increasingly interconnected: data is continuously exchanged and notifications are sent when certain events occur.
Until a few years ago, developing solid connections between different systems was no mean feat and required considerable effort.
In this tutorial, we will see how to make our Laravel application capable of sending notifications to external listening applications, using webhooks.
First, the basics: What is a Webhook?
Webhooks are HTTP callbacks that an external application (client) can subscribe to receive notifications from a main application (server).
Callbacks are therefore generally activated when an event occurs in the main application (server).
Why not just use API calls?
Our aim is to standardize outgoing calls. In other words, by exploiting the webhook mechanism, the server application does not need to adapt to the interface of each client, but it is the clients that must adapt and activate themselves based on the content of the standardized payload they will receive.
For example, think about Stripe: client applications sign up for new payment events. It is the clients that fit the Stripe specification and not the other way around.
The goal of the tutorial, in short
We will create a Laravel server app, capable of storing client subscriptions in DB. Each client will have a different Signature Secret Key.
For simplicity, subscriptions will be handled with a console command.
To handle the calls, we will use a very nice package:
š Laravel Webhook ServerThe package is developed and maintained by Spatie and we all know how much this is a guarantee!
Steps
- Install the Laravel Webhook Server package
- Make the model and migration for subscriptions
- Create a console command to subscribe to webhooks
- Add a general webhook event
- Add the event listener that makes the calls
- Example: how to dispatch the webhook event
1. Install the Laravel Webhook Server package
Install the package:
$ composer require spatie/laravel-webhook-server
Publish the config file in config/webhook-server.php
:
$ php artisan vendor:publish --provider="Spatie\WebhookServer\WebhookServerServiceProvider"
Let's extend the timeout for the calls to:
// config/webhook-server.php
/*
* If a call to a webhook takes longer that
* this amount of seconds
* the attempt will be considered failed.
*/
'timeout_in_seconds' => 30,
You can customize many parameters. To do that, you can follow the documentation of the package.
2. Make the model and migration for subscriptions
Make the model WebhookSubscription
with --migration
option:
$ php artisan make:model WebhookSubscription --migration
In the migration, let's add the following fields to the table webhook_subscriptions
:
Field | Description |
---|---|
url |
the url of the webhook (external app) |
signature_secret_key |
the secret key used to sign each call (to share with external app) |
is_active |
|
listen_events |
* for any event or comma separated values - for example: order:new,order:updated
|
// database/migrations/XXXX_create_webhook_subscriptions_table
return new class extends Migration
{
public function up(): void
{
Schema::create('webhook_subscriptions', function (Blueprint $table) {
$table->id();
$table->string('url', 180)->unique()
->comment('the url of the webhook (external app)');
$table->string('signature_secret_key', 128)
->comment('the secret key used to sign each call (to share with external app)');
$table->boolean('is_active')
->index()
->default(1);
$table->text('listen_events')
->default('*')
->comment('`*` for any event or comma separated values - for example: `order:new,order:updated`');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('webhook_subscriptions');
}
};
$ php artisan migrate
Ā
š” SMALL TIP
For this tutorial, we just need to use a SQLite DB, but of course you can use any other type of DB already used in your app.To use a SQLite DB:
# .env # ... DB_CONNECTION=sqlite DB_HOST= DB_PORT= DB_DATABASE=tony_webhook.sqlite
Ā
The model:
// app/Models/WebhookSubscription.php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WebhookSubscription extends Model
{
use HasFactory;
protected $fillable = [
'url',
'signature_secret_key',
'is_active',
'listen_events',
];
protected $casts = [
'is_active' => 'boolean',
];
protected static function booted()
{
self::saving(function($model) {
if (empty($model->signature_secret_key)) {
$model->signature_secret_key = \Str::random(64);
}
});
}
protected function listenEvents(): Attribute
{
return Attribute::make(
get: fn (?string $value) => is_string($value)
? array_map('trim', explode(',', $value))
: [],
set: fn (string|array|null $value) => is_array($value)
? implode(',', $value)
: $value,
);
}
public function isListenFor(string $event): bool
{
if ($this->isListenForAnyEvent()) {
return true;
}
return in_array($event, $this->listen_events);
}
public function isListenForAnyEvent(): bool
{
return in_array('*', $this->listen_events);
}
}
3. Create a console command to subscribe to webhooks
This is just a utility to add subscriptions via console, but you can add records via artisan tinker
or build the UI for that.
$ php artisan make:command WebhookSubscribe
// app/Console/Commands/WebhookSubscribe.php
namespace App\Console\Commands;
use App\Models\WebhookSubscription;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\PromptsForMissingInput;
class WebhookSubscribe extends Command implements PromptsForMissingInput
{
protected $signature = 'app:webhook-subscribe
{url : The URL of the webhook (external app)}
{events : The events that webhook may listen ("*" for any event or comma separated values - for example: "order:new,order:updated")}';
protected $description = 'Add a subscribed external app to webhook';
public function handle()
{
$url = trim($this->argument('url'));
$events = trim($this->argument('events'));
if (empty($url)) {
$this->error('The URL is required!');
return 1;
}
if (! \Str::of($url)->startsWith('https://')) {
$this->error('The URL must start with `https://`!');
return 1;
}
if (empty($events)) {
$events = '*';
}
$alreadyExists = WebhookSubscription::where('url', $url)->exists();
if ($alreadyExists) {
$this->error("The URL [{$url}] is already subscribed!");
return 1;
}
$WebhookSubscription = WebhookSubscription::create([
'url' => $url,
'listen_events' => $events,
'is_active' => true,
]);
$this->info('OK, Webhook Subscription created.');
$this->newLine();
$this->line('> The signature secret key is:');
$this->line("> <bg=magenta;fg=black>{$WebhookSubscription->signature_secret_key}</>");
$this->newLine();
$this->warn('(You have to pass this key to external app for checking signature)');
return 0;
}
}
Now, we have a new command. If we call the artisan list
, we get:
$ php artisan list
If we call the artisan help
, we get:
$ php artisan help app:webhook-subscribe
Create some subscriptions
Now, let's create a subscription example:
$ php artisan app:webhook-subscribe https://tony-webhook-client-1.test/webhook "*"
(NOTE the signature secret key that you must copy and send or use in client app)
4. Add a general webhook event
Now, let's make a general event that we will use in our app to notify all webhook subscribers.
$ php artisan make:event WebhookEvent
// app/Events/WebhookEvent.php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WebhookEvent
{
use Dispatchable, SerializesModels;
public function __construct(
public string $name,
public array $data,
) {}
}
5. Add the event listener that makes the calls
We have the WebhookEvent
: now let's create the related listener:
$ php artisan MakeWebhookCalls --event=WebhookEvent
// app/Listeners/MakeWebhookCalls.php
namespace App\Listeners;
use App\Events\WebhookEvent;
use App\Models\WebhookSubscription;
use Spatie\WebhookServer\WebhookCall;
class MakeWebhookCalls
{
public function handle(WebhookEvent $event): void
{
$subscriptions = WebhookSubscription::query()
->where('is_active', true)
->oldest()
->get();
foreach ($subscriptions as $subscription) {
if (! $subscription->isListenFor($event->name)) {
continue;
}
WebhookCall::create()
->url($subscription->url)
->payload([
'event' => $event->name,
'data' => $event->data
])
->useSecret($subscription->signature_secret_key)
->dispatch();
}
}
}
Don't forget to bind the event and the listener in the EventServiceProvider
class:
// app/Providers/EventServiceProvider.php
namespace App\Providers;
use App\Events\WebhookEvent;
use App\Listeners\MakeWebhookCalls;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
// ...
WebhookEvent::class => [
MakeWebhookCalls::class,
],
];
6. Example: how to dispatch the webhook event
Example 1: Customer new/updated
An example CustomerController
with 2 event dispatches (customer:new
and customer:updated
):
// app/Http/Controllers/CustomerController.php
namespace App\Http\Controllers;
use App\Events\WebhookEvent;
use Illuminate\Http\Request;
class CustomerController extends Controller
{
// ...
public function store(Request $request)
{
// customer store logic...
event(new WebhookEvent('customer:new', [
// any payload data
]));
}
public function update(Request $request)
{
// customer update logic...
event(new WebhookEvent('customer:updated', [
// any payload data
]));
}
}
Example 2: Order new/updated
Another example OrderController
with 2 event dispatches (order:new
and order:updated
):
// app/Http/Controllers/CustomerController.php
namespace App\Http\Controllers;
use App\Events\WebhookEvent;
use Illuminate\Http\Request;
class OrderController extends Controller
{
// ...
public function store(Request $request)
{
// order store logic...
event(new WebhookEvent('order:new', [
// any payload data
]));
}
public function update(Request $request)
{
// order update logic...
event(new WebhookEvent('order:updated', [
// any payload data
]));
}
}
āø Enjoy your coding!
Ā
If you liked this post, don't forget to add your Follow to my profile!
Posted on August 20, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.