How to manage subscribed webhooks in Laravel
Tags: laravel, webdev, tutorial, webhook
Published: 2023-08-20 18:21:38

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0dktpz0k2ja6fz2vy2ii.png)

**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](https://github.com/spatie/laravel-webhook-server)
>
> _The package is developed and maintained by [Spatie](https://spatie.be/) and we all know how much this is a guarantee!_

---

## Steps

1. [Install the Laravel Webhook Server package](#step-1)
2. [Make the model and migration for subscriptions](#step-2)
3. [Create a console command to subscribe to webhooks](#step-3)
4. [Add a general webhook event](#step-4)
5. [Add the event listener that makes the calls](#step-5)
6. [Example: how to dispatch the webhook event](#step-6)

---

### 1. Install the Laravel Webhook Server package <a name="step-1"></a>

Install the package:

```sh
$ composer require spatie/laravel-webhook-server
```


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

```sh
$ php artisan vendor:publish --provider="Spatie\WebhookServer\WebhookServerServiceProvider"
```

Let's extend the timeout for the calls to:

```php
// 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](https://github.com/spatie/laravel-webhook-server).

---

### 2. Make the model and migration for subscriptions <a name="step-2"></a>

Make the model `WebhookSubscription` with `--migration` option:

```sh
$ 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` |


```php
// 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');
    }
};
```

```sh
$ php artisan migrate
```

&nbsp;

> 💡 **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**:
> ```ini
> # .env
> # ...
> 
> DB_CONNECTION=sqlite
> DB_HOST=
> DB_PORT=
> DB_DATABASE=tony_webhook.sqlite
> ```

&nbsp;

The model:

```php
// 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 <a name="step-3"></a>

_This is just a utility to add subscriptions via console, but you can add records via `artisan tinker` or build the UI for that._

```sh
$ php artisan make:command WebhookSubscribe
```

```php
// 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:

```sh
$ php artisan list
```

![php artisan list](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zgivy4lvi3x3o1ml1o70.png)


If we call the `artisan help`, we get:

```sh
$ php artisan help app:webhook-subscribe
```

![php artisan help](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bl1xmvmsfwbh36xi4ali.png)


#### Create some subscriptions

Now, let's create a subscription example:

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

![Webhook subscribe](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5ydc1v5k4wgn5rr2ygj4.png)

(_NOTE the signature secret key that you must copy and send or use in client app_)

---

### 4. Add a general webhook event <a name="step-4"></a>

Now, let's make a general event that we will use in our app to notify all webhook subscribers.


```sh
$ php artisan make:event WebhookEvent
```

```php
// 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 <a name="step-5"></a>

We have the `WebhookEvent`: now let's create the related listener:

```sh
$ php artisan MakeWebhookCalls --event=WebhookEvent
```

```php
// 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:

```php
// 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 <a name="step-6"></a>

#### Example 1: Customer new/updated

An example `CustomerController` with 2 event dispatches (`customer:new` and `customer:updated`):

```php
// 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`):

```php
// 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!


&nbsp;

###### If you liked this post, don't forget to add your Follow to my profile!

{% user tonyjoe %}