Webhooks entrants robustes avec Laravel: HMAC, idempotence et jobs
Implémente un endpoint de webhooks sécurisé avec signature HMAC, idempotence en base et traitement asynchrone via jobs. Objectif: répondre 2xx vite, ne jamais traiter deux fois, observer et tester.
Sommaire
Webhooks entrants robustes avec Laravel: HMAC, idempotence et jobs
Recevoir des webhooks en production exige d’être à la fois rapide, fiable et sûr. Dans ce tutoriel, vous allez implémenter un endpoint de webhook avec Laravel qui vérifie une signature HMAC, garantit l’idempotence en base, exécute le traitement en file de jobs pour répondre en moins d’une seconde et expose des points d’observabilité pour diagnostiquer et tester.
Objectif
L’objectif est de livrer un point d’entrée HTTP qui valide chaque requête entrante via une signature HMAC, persiste l’événement pour éviter de le traiter plusieurs fois, délègue le traitement métier à un job asynchrone et renvoie systématiquement un statut HTTP 202 au plus vite. Vous pourrez ainsi absorber des pics de charge sans perte d’événements, tout en gardant une traçabilité complète (logs, métriques, horodatages) et une batterie de tests pour prévenir les régressions.
Pré-requis et configuration rapide
Commencez par activer les files d’attente. Dans votre fichier .env, définissez la connexion de queue sur la base de données, puis générez la table nécessaire et migrez le schéma.
# .env
QUEUE_CONNECTION=database
# Si vous utilisez le mutex via Cache::lock, préférez Redis pour le cache:
CACHE_STORE=redis
REDIS_CLIENT=phpredis
# secret de signature du webhook
WEBHOOK_SECRET=changeme
Ensuite, exécutez les commandes artisan correspondantes pour la queue.
php artisan queue:table
php artisan migrate
Si vous utilisez Redis pour les verrous atomiques du job (recommandé), assurez-vous que l’extension Redis PHP est installée ou ajoutez predis/predis si vous préférez le client PHP.
# Option 1: extension PHP Redis (recommandé sur Linux)
# sudo apt install php-redis && sudo service php-fpm restart
# Option 2: client PHP
composer require predis/predis
Ajoutez enfin un canal de logs dédié pour isoler les événements de webhook. Modifiez config/logging.php afin de créer un canal “webhooks” qui écrit dans storage/logs/webhooks.log.
// config/logging.php
'channels' => [
// ...
'webhooks' => [
'driver' => 'daily',
'path' => storage_path('logs/webhooks.log'),
'level' => 'info',
'days' => 14,
'replace_placeholders' => true,
],
],
Migration et modèle pour l’idempotence
Créez la migration de la table qui persistera chaque événement reçu. La clé d’idempotence sera le couple (provider, event_id) pour empêcher d’insérer deux fois le même message si le fournisseur réémet une notification.
php artisan make:migration create_webhook_events_table
Éditez la migration pour inclure les colonnes utiles, les index et les contraintes d’unicité.
// database/migrations/xxxx_xx_xx_xxxxxx_create_webhook_events_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('provider', 64);
$table->string('event_id', 191);
$table->string('signature', 512)->nullable();
$table->json('payload');
$table->timestamp('received_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->string('status', 32)->default('received'); // received|processing|processed|failed|skipped
$table->text('error')->nullable();
$table->timestamps();
$table->unique(['provider', 'event_id']);
$table->index('status');
});
}
public function down(): void {
Schema::dropIfExists('webhook_events');
}
};
Appliquez la migration.
php artisan migrate
Créez ensuite le modèle Eloquent, avec des casts pour manipuler le JSON et les timestamps de façon ergonomique. Générez la classe et complétez-la pour peupler automatiquement la colonne uuid.
php artisan make:model WebhookEvent
// app/Models/WebhookEvent.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class WebhookEvent extends Model
{
protected $fillable = [
'uuid', 'provider', 'event_id', 'signature', 'payload',
'received_at', 'processed_at', 'status', 'error',
];
protected $casts = [
'payload' => 'array',
'received_at' => 'datetime',
'processed_at' => 'datetime',
];
protected static function booted(): void
{
static::creating(function (self $event) {
if (empty($event->uuid)) {
$event->uuid = (string) Str::uuid();
}
});
}
}
Avec cette table, chaque webhook est durablement stocké, ce qui vous permet de rejouer, auditer et garantir l’idempotence via l’unicité en base.
Middleware de vérification HMAC
Un middleware dédié doit valider la signature HMAC avant d’atteindre votre contrôleur. Créez la classe et implémentez la logique suivante: lire le corps brut, récupérer la date du message, reconstruire la base de signature “timestamp.body”, calculer le HMAC en sha256 avec votre secret, comparer via hash_equals et vérifier que le timestamp ne dérive pas de plus de cinq minutes.
php artisan make:middleware VerifyWebhookSignature
// app/Http/Middleware/VerifyWebhookSignature.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
class VerifyWebhookSignature
{
public function handle(Request $request, Closure $next)
{
$body = $request->getContent();
$timestamp = $request->header('X-Signature-Timestamp');
$rawSignature = $request->header('X-Signature') // générique
?? $request->header('X-Hub-Signature-256') // GitHub: "sha256=<hex>"
?? $request->header('Stripe-Signature'); // Stripe: format spécifique
if (!$timestamp) {
return response()->json(['message' => 'Missing X-Signature-Timestamp'], 400);
}
// Dérive temporelle max +/- 5 minutes
$now = Carbon::now()->getTimestamp();
if (abs($now - (int) $timestamp) > 300) {
return response()->json(['message' => 'Stale or invalid timestamp'], 401);
}
// Secret depuis la config si dispo, sinon .env
$currentSecret = config('services.webhooks.secret', env('WEBHOOK_SECRET'));
// Support GitHub "sha256=..." et format générique "hex"
$expected = hash_hmac('sha256', "{$timestamp}.{$body}", $currentSecret);
$provided = $this->normalizeProvidedSignature($rawSignature);
if (!hash_equals($expected, $provided)) {
Log::channel('webhooks')->warning('Invalid webhook signature', [
'request_id' => $request->header('X-Request-Id'),
'ip' => $request->ip(),
]);
return response()->json(['message' => 'Invalid signature'], 401);
}
return $next($request);
}
private function normalizeProvidedSignature(?string $raw): string
{
if (!$raw) {
return '';
}
// GitHub: "sha256=<hexdigest>"
if (str_contains($raw, '=')) {
[$algo, $sig] = explode('=', $raw, 2);
return $sig;
}
// Stripe a un format particulier (t=v1, s=...), non traité ici volontairement
// Dans un projet Stripe, utilisez \Stripe\Webhook::constructEvent().
return $raw;
}
}
Enregistrez un alias de middleware pour simplifier la route. Ouvrez app/Http/Kernel.php et ajoutez-le dans $routeMiddleware.
// app/Http/Kernel.php
protected $routeMiddleware = [
// ...
'verify.webhook' => \App\Http\Middleware\VerifyWebhookSignature::class,
];
Ce middleware refusera toute requête dont le timestamp est absent ou hors plage, ou dont la signature calculée ne correspond pas à celle fournie.
Route et contrôleur invocable minimal
Exposez une route POST dédiée aux webhooks, protégée par le middleware de signature. Créez ensuite un contrôleur invocable qui lit le JSON, applique l’idempotence et déclenche le job.
// routes/api.php (ou routes/web.php selon votre préférence)
use App\Http\Controllers\IncomingWebhookController;
use Illuminate\Support\Facades\Route;
Route::middleware('verify.webhook')->post('/webhooks/{provider}', IncomingWebhookController::class)
->name('webhooks.handle');
Générez le contrôleur invocable.
php artisan make:controller IncomingWebhookController --invokable
Implémentez une extraction robuste de l’identifiant d’événement. Selon les fournisseurs, il peut s’appeler id, event_id, guid, ou être imbriqué. Pour l’exemple, on tente plusieurs clés et on journalise si la valeur est ambiguë.
// app/Http/Controllers/IncomingWebhookController.php
namespace App\Http\Controllers;
use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class IncomingWebhookController extends Controller
{
public function __invoke(Request $request, string $provider): JsonResponse
{
$payload = $request->json()->all();
$signature = $request->header('X-Signature')
?? $request->header('X-Hub-Signature-256')
?? $request->header('Stripe-Signature');
$eventId = $payload['id']
?? $payload['event_id']
?? ($payload['data']['id'] ?? null);
if (!$eventId) {
// Dernier recours: refuser si l’ID est introuvable
return response()->json(['message' => 'Missing event_id in payload'], 422);
}
$eventType = $payload['type'] ?? $payload['event_type'] ?? 'unknown';
try {
$event = WebhookEvent::firstOrCreate(
['provider' => $provider, 'event_id' => (string) $eventId],
['uuid' => (string) Str::uuid()]
);
} catch (QueryException $e) {
// Conflit de concurrence: récupère l’événement existant
$event = WebhookEvent::where('provider', $provider)
->where('event_id', (string) $eventId)
->firstOrFail();
}
// Mettez à jour les autres champs à chaque réception
$event->signature = $signature;
$event->payload = $payload;
$event->received_at = now();
$event->status = $event->wasRecentlyCreated ? 'received' : 'received';
$event->save();
// Dispatch non bloquant; la réponse doit arriver vite
Bus::dispatch(new ProcessWebhookEvent($event->id, $eventType))
->onQueue('webhooks');
return response()->json([
'status' => 'accepted',
'uuid' => $event->uuid,
], 202);
}
}
La route répond systématiquement 202 Accepted en quelques millisecondes, même si le traitement métier prend plus de temps. Le payload brut est persisté et pourra être réanalysé côté job.
Mutex et job asynchrone robuste
Créez un job ShouldQueue qui verrouille le traitement pour empêcher les doublons concurrents, exécute la logique métier dans une transaction et trace les erreurs. Le verrou atomique via Cache::lock nécessite un store compatible (Redis, Memcached, DynamoDB).
php artisan make:job ProcessWebhookEvent
// app/Jobs/ProcessWebhookEvent.php
namespace App\Jobs;
use App\Models\WebhookEvent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\DatabaseManager;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class ProcessWebhookEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $backoff = 10; // secondes entre tentatives
public function __construct(
public int $eventId,
public string $eventType
) {}
public function middleware(): array
{
// Exemple: limiter le débit global si nécessaire
return [
// new RateLimited('webhooks-processing'), // nécessite un RateLimiter
];
}
public function retryUntil(): \DateTime
{
// Arrêter de réessayer au-delà de 10 minutes
return now()->addMinutes(10)->toDateTime();
}
public function handle(): void
{
$event = WebhookEvent::findOrFail($this->eventId);
$lock = Cache::lock('we:' . $event->id, 30);
$lock->block(5, function () use ($event) {
// Éviter de retraiter un événement déjà finalisé
if (in_array($event->status, ['processed', 'skipped'], true)) {
Log::channel('webhooks')->info('Event already finalized, skipping', [
'provider' => $event->provider,
'event_id' => $event->event_id,
]);
return;
}
$event->status = 'processing';
$event->save();
try {
DB::transaction(function () use ($event) {
// Sélection logique selon le type
$type = $this->eventType;
if ($type === 'user.created') {
// Exemple métier: créer ou mettre à jour un utilisateur
// User::updateOrCreate([...]);
Log::channel('webhooks')->info('Handled user.created', [
'provider' => $event->provider,
'event_id' => $event->event_id,
]);
} elseif ($type === 'invoice.paid') {
// Exemple métier: marquer une facture comme payée
Log::channel('webhooks')->info('Handled invoice.paid', [
'provider' => $event->provider,
'event_id' => $event->event_id,
]);
} else {
// Type non supporté: marquez comme ignoré
$event->status = 'skipped';
$event->processed_at = now();
$event->save();
Log::channel('webhooks')->notice('Unsupported event type, skipped', [
'provider' => $event->provider,
'event_id' => $event->event_id,
'type' => $type,
]);
return;
}
// Si tout s’est bien passé, finalisez
$event->status = 'processed';
$event->processed_at = now();
$event->error = null;
$event->save();
});
} catch (Throwable $e) {
// En cas d’erreur, enregistrez le diagnostic et laissez la queue retenter
$event->status = 'failed';
$event->error = $e->getMessage();
$event->save();
Log::channel('webhooks')->error('Webhook job failed', [
'provider' => $event->provider,
'event_id' => $event->event_id,
'exception' => $e,
]);
// Relancer l’exception pour déclencher le retry
throw $e;
}
});
}
}
Démarrez un worker de queue dédié pour ces jobs. L’utilisation d’un nom de queue distinct permet de séparer la capacité de traitement.
php artisan queue:work --queue=webhooks,default --tries=3
Le mutex bloque les exécutions concurrentes du même événement sur un cluster de workers. La transaction base de données assure que vos effets métier et la mise à jour du statut restent synchrones.
Rate limiting et sécurité périphérique
Appliquez une limitation de débit spécifique pour vos webhooks afin de prévenir les abus tout en respectant le rythme attendu des fournisseurs. Dans RouteServiceProvider, déclarez un RateLimiter nommé et référencez-le dans votre route.
// app/Providers/RouteServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('webhooks', function (Request $request) {
// 30 requêtes/min par IP et par provider
$key = sprintf('webhooks:%s:%s', $request->ip(), $request->route('provider'));
return [Limit::perMinute(30)->by($key)];
});
parent::boot();
}
Appliquez le throttle sur la route.
Route::middleware(['verify.webhook', 'throttle:webhooks'])
->post('/webhooks/{provider}', IncomingWebhookController::class)
->name('webhooks.handle');
Exemptez la route de la protection CSRF, puisqu’elle est appelée par des tiers. Ajoutez un motif dans app/Http/Middleware/VerifyCsrfToken.php.
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'webhooks/*',
];
Limitez la taille maximale du corps et le délai d’exécution côté serveur web et PHP pour réduire l’impact de requêtes malveillantes. Par exemple, côté Nginx vous pouvez réduire client_max_body_size et ajuster les timeouts.
# nginx.conf (server)
client_max_body_size 1m;
proxy_read_timeout 15s;
proxy_connect_timeout 5s;
proxy_send_timeout 15s;
Et côté PHP, adaptez post_max_size et max_input_time selon vos besoins.
; php.ini
post_max_size = 2M
max_input_time = 30
Si vous connaissez la plage d’adresses IP de votre fournisseur, vous pouvez autoriser-list ces IP au niveau du pare-feu ou du reverse proxy. Imposer la présence de certains en-têtes comme X-Request-Id peut également aider au traçage.
Observabilité: logs et métriques
Consignez chaque réception et chaque fin de traitement avec un contexte riche. Lors de la réception, journalisez le provider, l’event_id, le request_id et l’adresse IP. Lors du traitement, enregistrez les transitions d’état et les erreurs avec la trace exception.
// Exemple de log à la réception (contrôleur)
Log::channel('webhooks')->info('Received webhook', [
'provider' => $provider,
'event_id' => $eventId,
'request_id' => $request->header('X-Request-Id'),
'ip' => $request->ip(),
]);
// Exemple à la fin du job
Log::channel('webhooks')->info('Processed webhook', [
'provider' => $event->provider,
'event_id' => $event->event_id,
'latency_ms' => optional($event->processed_at)->diffInMilliseconds($event->received_at),
]);
Pour exposer des métriques, vous pouvez intégrer un client Prometheus ou StatsD et incrémenter des compteurs processed, failed et skipped. Par exemple, avec un wrapper StatsD, incrémentez au moment opportun.
// Pseudo-exemple si vous avez un client StatsD configuré
// StatsD::increment('webhooks.received', tags: ["provider:{$provider}"]);
// StatsD::increment('webhooks.processed', tags: ["provider:{$event->provider}", "type:{$type}"]);
Activez Laravel Telescope en environnement de développement pour visualiser les requêtes entrantes, les jobs, les logs et les exceptions de manière centralisée.
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
La latence de bout en bout s’obtient simplement via la différence processed_at − received_at et peut être affichée dans un dashboard de supervision.
Tests automatisés avec Pest
Validez chaque scénario critique via des tests de feature Pest. Commencez par générer les tests puis écrivez des cas qui reproduisent des signatures valides/invalides, l’idempotence et les horodatages expirés.
composer require pestphp/pest --dev
php artisan pest:install
Créez un fichier de test dédié.
// tests/Feature/WebhooksTest.php
use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
beforeEach(function () {
config()->set('services.webhooks.secret', 'testing-secret');
});
function signPayload(array $payload, int $ts, string $secret): string {
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
return hash_hmac('sha256', "{$ts}.{$body}", $secret);
}
it('accepte une signature valide et crée un seul événement', function () {
Bus::fake();
$payload = ['id' => (string) Str::uuid(), 'type' => 'user.created', 'data' => ['email' => 'a@example.test']];
$ts = time();
$sig = signPayload($payload, $ts, config('services.webhooks.secret'));
$res = $this->withHeaders([
'X-Signature-Timestamp' => (string) $ts,
'X-Signature' => $sig,
'Content-Type' => 'application/json',
])->postJson('/webhooks/demo', $payload);
$res->assertStatus(202);
expect(WebhookEvent::count())->toBe(1);
Bus::assertDispatched(ProcessWebhookEvent::class, 1);
});
it('rejette une signature invalide et ne dispatch pas de job', function () {
Bus::fake();
$payload = ['id' => 'evt_123', 'type' => 'user.created'];
$ts = time();
$badSig = 'deadbeef';
$res = $this->withHeaders([
'X-Signature-Timestamp' => (string) $ts,
'X-Signature' => $badSig,
'Content-Type' => 'application/json',
])->postJson('/webhooks/demo', $payload);
$res->assertStatus(401);
expect(WebhookEvent::count())->toBe(0);
Bus::assertNotDispatched(ProcessWebhookEvent::class);
});
it('est idempotent: deux POST du même event_id ne créent qu’un enregistrement et un seul job', function () {
Bus::fake();
$id = (string) Str::uuid();
$payload = ['id' => $id, 'type' => 'user.created'];
$ts = time();
$sig = signPayload($payload, $ts, config('services.webhooks.secret'));
$headers = [
'X-Signature-Timestamp' => (string) $ts,
'X-Signature' => $sig,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)->postJson('/webhooks/demo', $payload)->assertStatus(202);
// Simule une rediffusion du fournisseur
$this->withHeaders($headers)->postJson('/webhooks/demo', $payload)->assertStatus(202);
expect(WebhookEvent::count())->toBe(1);
Bus::assertDispatched(ProcessWebhookEvent::class); // potentiellement 2 dispatch, mais un seul traitement effectif grâce au mutex
});
it('rejette un timestamp expiré', function () {
Bus::fake();
$payload = ['id' => 'evt_999', 'type' => 'user.created'];
$ts = time() - 3600; // 1 h dans le passé
$sig = signPayload($payload, $ts, config('services.webhooks.secret'));
$res = $this->withHeaders([
'X-Signature-Timestamp' => (string) $ts,
'X-Signature' => $sig,
'Content-Type' => 'application/json',
])->postJson('/webhooks/demo', $payload);
$res->assertStatus(401);
Bus::assertNotDispatched(ProcessWebhookEvent::class);
});
Dans ces tests, Bus::fake intercepte le dispatch des jobs pour l’assertion. Le calcul de la signature reproduit fidèlement la logique du middleware afin de simuler des requêtes réalistes.
Tests manuels: ngrok et curl
Exposez votre application en local via ngrok, générez une signature en ligne de commande et envoyez une requête pour vérifier le flux de bout en bout. Démarrez d’abord le serveur de dev puis ngrok.
php artisan serve --host=127.0.0.1 --port=8000
ngrok http 8000
Construisez un payload JSON, calculez le timestamp et la signature HMAC, puis envoyez la requête signée.
BODY='{"id":"evt_demo_1","type":"user.created","data":{"email":"demo@example.test"}}'
TS=$(date +%s)
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary | xxd -p -c 256)
curl -i -X POST \
-H "Content-Type: application/json" \
-H "X-Signature: $SIG" \
-H "X-Signature-Timestamp: $TS" \
-d "$BODY" \
https://<votre-sous-domaine-ngrok>/webhooks/demo
Contrôlez la réponse 202 et surveillez le fichier storage/logs/webhooks.log ainsi que la table webhook_events. Vous devez voir le statut évoluer de received à processed (ou skipped selon le type).
Durcissement en production et variantes
Pour la rotation des secrets, acceptez à la fois le secret courant et le précédent pendant une fenêtre de grâce. Modifiez le middleware pour essayer plusieurs secrets dans l’ordre.
// config/services.php
'webhooks' => [
'secret' => env('WEBHOOK_SECRET'),
'previous_secret' => env('WEBHOOK_PREVIOUS_SECRET'), // optionnel
],
// extrait dans VerifyWebhookSignature
$secrets = array_filter([
config('services.webhooks.secret'),
config('services.webhooks.previous_secret'),
]);
$valid = false;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
if (hash_equals($expected, $provided)) {
$valid = true;
break;
}
}
if (!$valid) {
return response()->json(['message' => 'Invalid signature'], 401);
}
Définissez des SLA clairs sur la latence de réponse, par exemple un 99e percentile sous 500 ms. Gardez le contrôleur minimal et déléguez au job afin de respecter cet objectif même quand vos dépendances externes sont lentes. Pour isoler les fournisseurs volumineux, routez les jobs sur des files spécifiques.
// Exemple de routage de queue par provider
$queue = match ($provider) {
'stripe' => 'webhooks-high',
'github' => 'webhooks-low',
default => 'webhooks',
};
Bus::dispatch(new ProcessWebhookEvent($event->id, $eventType))->onQueue($queue);
Adaptez enfin la vérification de signature aux particularités des fournisseurs. GitHub utilise l’en-tête X-Hub-Signature-256 au format sha256=
Checklist
Avant de déployer, prenez le temps de relire le code et les messages de log pour vérifier la cohérence des noms d’en-têtes, des clés d’événement et des types. Exécutez toutes les commandes artisan et les snippets proposés pour s’assurer que les migrations, les workers de queue et Telescope fonctionnent correctement en local. Effectuez la suite de tests automatisés Pest et complétez-la avec des cas propres à vos fournisseurs. Publiez ensuite derrière un reverse proxy configuré (timeouts, taille de corps, IP allowlist éventuellement) et surveillez les métriques processed, failed et la latence réception→traitement durant la montée en charge initiale.
Conclusion
Vous disposez maintenant d’une implémentation de webhooks robuste avec Laravel, combinant vérification HMAC, idempotence en base, exécution asynchrone via jobs et une observabilité pratique. Cette architecture est résiliente face aux redéliveries, aux pics de trafic et aux incidents transitoires. Elle se décline facilement pour des fournisseurs variés en adaptant l’extraction de l’event_id et la vérification de signature.
Ressources
- Laravel — Documentation Queues: https://laravel.com/docs/queues
- Laravel — Jobs et Middleware de queue: https://laravel.com/docs/queues#job-middleware
- Laravel — Cache et verrous atomiques: https://laravel.com/docs/cache#atomic-locks
- Laravel — Logging: https://laravel.com/docs/logging
- Laravel — Rate Limiting: https://laravel.com/docs/rate-limiting
- Laravel — HTTP Tests: https://laravel.com/docs/http-tests
- Pest PHP: https://pestphp.com/
- Laravel Telescope: https://laravel.com/docs/telescope
- GitHub Webhook signatures: https://docs.github.com/webhooks/using-webhooks/validating-webhook-deliveries
- Stripe Webhooks et signatures: https://stripe.com/docs/webhooks
- ngrok: https://ngrok.com/