How to manage subscribed webhooks in Laravel

tonyjoe

Tony Joe Dev

Posted on August 20, 2023

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
Enter fullscreen mode Exit fullscreen mode

Publish the config file in config/webhook-server.php:

$ php artisan vendor:publish --provider="Spatie\WebhookServer\WebhookServerServiceProvider"
Enter fullscreen mode Exit fullscreen mode

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,

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
    }
};
Enter fullscreen mode Exit fullscreen mode
$ php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Ā 

šŸ’” 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);
    }

}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, we have a new command. If we call the artisan list, we get:

$ php artisan list
Enter fullscreen mode Exit fullscreen mode

php artisan list

If we call the artisan help, we get:

$ php artisan help app:webhook-subscribe
Enter fullscreen mode Exit fullscreen mode

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 "*"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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,
    ) {}

}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
        ],
    ];
Enter fullscreen mode Exit fullscreen mode

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
        ]));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        ]));
    }
}
Enter fullscreen mode Exit fullscreen mode

āœø Enjoy your coding!

Ā 

If you liked this post, don't forget to add your Follow to my profile!
šŸ’– šŸ’Ŗ šŸ™… šŸš©
tonyjoe
Tony Joe Dev

Posted on August 20, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related