How to manage subscribed webhooks in Laravel

Let's create a Laravel server app that can store client subscriptions, each with its own different Signature Secret Key

Aug 27, 2023ยท

7 min read

How to manage subscribed webhooks in Laravel

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?

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 Server

The package is developed and maintained by Spatie and we all know how much this is a guarantee!


Steps

  1. Install the Laravel Webhook Server package

  2. Make the model and migration for subscriptions

  3. Create a console command to subscribe to webhooks

  4. Add a general webhook event

  5. Add the event listener that makes the calls

  6. 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' => 10,

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:

FieldDescription
urlthe URL of the webhook (external app)
signature_secret_keythe 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

php artisan list

If we call the artisan help, we get:

php artisan help app:webhook-subscribe

php artisan help

Create some subscriptions

Now, let's create a subscription example:

php artisan app:webhook-subscribe https://tony-webhook-client-1.test/webhook "*"

Webhook subscribe

(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 Subscribe to my newsletter!

ย