Skip to content

Webhooks

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

The Webhook Component provides a complete, enterprise-grade solution for receiving, verifying, and processing webhooks from external providers like Stripe, GitHub, PayPal, Shopify, and more. It handles the complex parts of webhook integration so you can focus on business logic.

What is a Webhook?

A webhook is a way for an application to provide other applications with real-time information. When an event happens in the source system (e.g., a payment succeeds in Stripe), it sends an HTTP POST request to a URL you specify (your webhook endpoint).

Why Use This Component?

Without proper infrastructure, webhook handling is deceptively complex:

  • Signature verification – proving the request really came from the provider
  • Idempotency – providers retry failed deliveries, so you may receive the same event twice
  • Concurrency – multiple requests for the same event may arrive simultaneously
  • Async processing – responding quickly while heavy work happens in the background
  • Failure handling – what happens when your handler throws an exception?

This component solves all of the above with battle-tested patterns.


Features

Feature Description
Signature Verification HMAC, HMAC-with-timestamp (Stripe-style), and custom verifiers
Idempotency Cache-based locking prevents duplicate processing
Async Processing Queue-based handling for long-running tasks
Multi-Provider Support unlimited providers with per-provider config
Multiple Handlers Route events to one or many handlers
Flexible Event Routing Match exact events, wildcards, or custom resolvers
Failure Modes Choose to stop or continue on handler failure
Code Generation Artisan command scaffolds everything you need
Structured Logging Full observability out of the box

Installation

Registration

Register the component's service provider (if not auto-discovered):

// config/providers.php
return [
    \Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookServiceProvider::class,
];

Generate Your First Provider

The fastest way to get started is via the make:webhook command:

php artisan make:webhook Stripe

This generates:

  • ✅ App\Controllers\StripeWebhookController – receives HTTP requests
  • ✅ App\Webhooks\Handlers\StripeWebhookHandler – your business logic
  • ✅ App\Providers\StripeWebhookServiceProvider – registers the handler
  • ✅ Configuration entry in config/core/webhooks.php

Add the Route

// routes/web.php
$router->post('/webhook/stripe', [StripeWebhookController::class, 'handle']);

Set the Secret

# .env
STRIPE_WEBHOOK_SECRET=whsec_your_secret_here

Quick Start

Here's a complete working example in 4 files:

1. Configuration — config/core/webhooks.php

return [
    'providers' => [
        'stripe' => [
            'secret' => getenv('STRIPE_WEBHOOK_SECRET'),
            'async' => false,
            'verify_signature' => true,
            'event_resolver' => ['type' => 'type'], // JSON path to event type
            'handler' => \App\Webhooks\Handlers\StripeWebhookHandler::class,
            'handler_failure_mode' => 'stop',
            'verify' => [
                'type'   => 'hmac_timestamp',
                'header' => 'Stripe-Signature',
                'algo'   => 'sha256',
                'prefix' => '',
            ],
        ],
    ],
];

2. Controller — app/Controllers/StripeWebhookController.php

<?php

namespace App\Controllers;

use Mlangeni\Machinjiri\Core\Http\HttpRequest;
use Mlangeni\Machinjiri\Core\Http\HttpResponse;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookPayload;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookManager;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookSubscriptionManager;

class StripeWebhookController
{
    public function handle(HttpRequest $request): string
    {
        $payload = WebhookPayload::fromHttpRequest(
            $request,
            'stripe',
            'Stripe-Idempotency-Key',   // optional, but recommended
            [
                'event_resolver' => ['type' => 'type'],
            ]
        );

        $manager = resolve(WebhookManager::class);
        return $manager->process($payload)->toJson();
    }
}

3. Handler — app/Webhooks/Handlers/StripeWebhookHandler.php

<?php

namespace App\Webhooks\Handlers;

use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookHandlerInterface;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookPayload;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookResponse;

class StripeWebhookHandler implements WebhookHandlerInterface
{
    public function handle(WebhookPayload $payload): WebhookResponse
    {
        match ($payload->getEventType()) {
            'payment_intent.succeeded' => $this->onPaymentSucceeded($payload),
            'payment_intent.failed'    => $this->onPaymentFailed($payload),
            default                    => null,
        };

        return WebhookResponse::ok();
    }

    public function supportsEvent(): string|array
    {
        return ['payment_intent.succeeded', 'payment_intent.failed'];
    }

    private function onPaymentSucceeded(WebhookPayload $payload): void
    {
        $amount = $payload->get('data.object.amount');
        $customer = $payload->get('data.object.customer');
        // ... fulfill order, send email, etc.
    }

    private function onPaymentFailed(WebhookPayload $payload): void
    {
        // ... notify customer, log, etc.
    }
}

4. Register the Handler

// app/Providers/StripeWebhookServiceProvider.php
public function register(): void
{
    $manager = resolve(WebhookSubscriptionManager::class);
    $manager->registerHandler(new \App\Webhooks\Handlers\StripeWebhookHandler());
}

That's it. You now have a production-ready webhook endpoint.


Core Concepts

Providers

A provider represents an external service (Stripe, GitHub, etc.). Each provider has its own:

  • secret for signature verification
  • verification method (HMAC, Stripe-style, custom)
  • event resolver (where to find the event type in the payload)
  • handler(s) to process events
  • sync/async mode

Handlers

A handler implements WebhookHandlerInterface. It receives a WebhookPayload and returns a WebhookResponse. A handler declares which events it supportsEvent() — a specific list, or '*' for all events.

Payload

The WebhookPayload wraps the incoming HTTP request, exposing:

  • getRawBody() – the raw request body (essential for signature verification)
  • getParsedData() – decoded body (JSON or form-encoded)
  • getEventType() – resolved event name
  • getProvider() – which provider sent this
  • getIdempotencyKey() – unique key for deduplication
  • get('data.object.amount') – dot-notation accessor

Idempotency

Providers retry failed webhooks. Without deduplication you could charge a customer twice. This component uses an IdempotencyStore (default: CacheIdempotencyStore) with two states per key:

  • lock – "someone is processing this right now"
  • done – "this was successfully processed"

The key is derived from provider + idempotencyKey. If no idempotency key exists in the request, deduplication is skipped (but this is not ideal — most providers send one).

Sync vs Async

Mode Behavior Use When
Sync Handlers run during the HTTP request Handler is fast (<2s), e.g. updating a record
Async Request returns 202 immediately, WebhookJob runs handlers on a queue Handler is slow (API calls, emails, PDFs)

Always prefer async for anything non-trivial — most providers require a response within a few seconds or they'll retry.


Architecture

                            ┌───────────────────────────┐
  POST /webhook/stripe ───► │  StripeWebhookController  │
                            └──────────────┬────────────┘
                                           │
                                           ▼
                            ┌───────────────────────────┐
                            │    WebhookPayload         │
                            │  (raw body, headers,      │
                            │   event, idempotency key) │
                            └──────────────┬────────────┘
                                           │
                                           ▼
                            ┌───────────────────────────┐
                            │     WebhookManager        │
                            │  ┌─────────────────────┐  │
                            │  │ 1. Verify signature │  │
                            │  ├─────────────────────┤  │
                            │  │ 2. Check idempotency│  │
                            │  ├─────────────────────┤  │
                            │  │ 3. Sync or async?   │  │
                            │  └─────────────────────┘  │
                            └───────┬─────────────┬─────┘
                                    │             │
                              sync  │             │ async
                                    ▼             ▼
                    ┌──────────────────┐  ┌──────────────────┐
                    │ dispatchToHandlers│  │  WebhookJob      │
                    │  (in-process)     │  │  (queue)         │
                    └────────┬──────────┘  └────────┬─────────┘
                             │                      │
                             └──────────┬───────────┘
                                        ▼
                         ┌────────────────────────────┐
                         │   WebhookHandlerInterface  │
                         │   (your business logic)    │
                         └────────────────────────────┘

Class Reference

Class Responsibility
WebhookManager Orchestrator — verifies, deduplicates, dispatches
WebhookPayload Immutable value object wrapping the request
WebhookResponse Response value object (status, headers, body)
WebhookSubscriptionManager Config + handler registry
WebhookHandlerInterface Contract for handlers
WebhookSignatureVerifier Static verification helpers
CacheIdempotencyStore Cache-backed idempotency store
IdempotencyStore Idempotency contract (swap the backend)
WebhookJob Async processing job
WebhookCommand make:webhook scaffolder

Basic Usage

Verifying Signatures

Signatures prove a webhook came from the real provider (not an attacker). Never skip this in production.

Three verification types are supported:

1. Standard HMAC

Used by GitHub, Shopify, and others. The provider sends an X-Signature header containing hash_hmac(algo, rawBody, secret).

'verify' => [
    'type'   => 'hmac',
    'header' => 'X-Hub-Signature-256',
    'algo'   => 'sha256',
    'prefix' => 'sha256=',   // strip prefix before comparing
],

2. HMAC with Timestamp (Stripe-style)

Format: t=1234567890,v1=abc123def.... Prevents replay attacks by rejecting old timestamps.

'verify' => [
    'type'      => 'hmac_timestamp',
    'header'    => 'Stripe-Signature',
    'algo'      => 'sha256',
    'prefix'    => '',
    'tolerance' => 300,   // reject if older than 5 minutes
],

3. Custom Verifier

For exotic providers, supply a callback:

'verify' => [
    'type'     => 'custom',
    'callback' => function(string $payload, array $headers, string $secret): bool {
        // Your logic
        return true;
    },
],

Accessing Payload Data

$payload->get('data.object.amount');          // dot notation
$payload->get('data.object.customer', null);  // with default
$payload->getParsedData();                    // full array
$payload->getRawBody();                       // raw string
$payload->getHeaders();                       // header array
$payload->getEventType();                     // "payment_intent.succeeded"
$payload->getProvider();                      // "stripe"
$payload->getIdempotencyKey();                // null or string

Returning Responses

return WebhookResponse::ok();                 // 200
return WebhookResponse::ok(['id' => 42]);     // 200 with JSON body
return WebhookResponse::accepted();           // 202
return WebhookResponse::badRequest('nope');   // 400
return WebhookResponse::unauthorized();       // 401
return WebhookResponse::notFound();           // 404

Event Resolution

Where is the event type in the JSON? Depends on the provider:

// Stripe: {"type": "payment_intent.succeeded", ...}
'event_resolver' => ['type' => 'type'],

// GitHub: {"action": "opened", ...}
'event_resolver' => ['type' => 'action'],

// Nested: {"data": {"type": "order.created"}}
'event_resolver' => ['type' => 'data.type'],

// Custom callback
'event_resolver' => [
    'callback' => fn($request, $parsed) => $parsed['event_name'] ?? 'unknown',
],

If no resolver is configured, the system falls back to headers: X-Event-Name, X-GitHub-Event, X-Stripe-Event.


Advanced Usage

Multiple Handlers Per Provider

A single event can trigger multiple handlers (e.g., analytics + fulfillment):

$manager = resolve(WebhookSubscriptionManager::class);
$manager->registerHandler(new PaymentFulfillmentHandler());
$manager->registerHandler(new PaymentAnalyticsHandler());
$manager->registerHandler(new PaymentAuditLogHandler());

All three run in registration order. Control failure behavior with handler_failure_mode:

  • 'stop' (default) — if any handler throws, stop and re-throw
  • 'continue' — log the error and run the remaining handlers
'handler_failure_mode' => 'continue',

Wildcard Handlers

supportsEvent() can return '*' to receive every event for the provider:

public function supportsEvent(): string|array
{
    return '*';
}

You can mix wildcard and specific handlers — all matching handlers run.

return ['payment.succeeded', 'payment.failed'];   // specific
return '*';                                        // all events

Async Processing with Queues

For slow handlers, enable async:

'async' => true,

The request returns 202 Accepted immediately. Handlers run via WebhookJob on the webhooks queue. The job retries up to 3 times with 60s delay.

Important: with async, idempotency is enforced inside the job — not at the HTTP layer. This ensures that if the job is retried, it won't reprocess.

Customizing the Queue Worker

php artisan queue:work --queue=webhooks --tries=3

Custom Idempotency Store

Swap the cache backend by implementing IdempotencyStore:

class DatabaseIdempotencyStore implements IdempotencyStore
{
    public function lock(string $key, int $ttl = 3600): bool { /* ... */ }
    public function markDone(string $key): void { /* ... */ }
    public function isDone(string $key): bool { /* ... */ }
}

Then inject it into WebhookManager (requires a small override — see the constructor).

Idempotency Key Per Provider

The idempotency header varies:

Provider Header
Stripe Stripe-Idempotency-Key or event id
GitHub X-GitHub-Delivery
Shopify X-Shopify-Event-Id
PayPal Paypal-Request-Id

Pass the header name to WebhookPayload::fromHttpRequest():

WebhookPayload::fromHttpRequest($request, 'github', 'X-GitHub-Delivery');

If a provider doesn't send an idempotency header, you can synthesize one in your controller:

$key = hash('sha256', $request->getBody());
// then build the payload manually, or pass a modified request

Testing Webhooks Locally

Using the CLI

Most providers ship a CLI forwarder (e.g., stripe listen --forward-to localhost:8000/webhook/stripe). This is the recommended approach because it signs test requests correctly.

Unit Testing Handlers

use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookPayload;

public function test_payment_succeeded()
{
    $payload = new WebhookPayload(
        rawBody: '{}',
        headers: [],
        parsedData: ['data' => ['object' => ['amount' => 1000, 'customer' => 'cus_123']]],
        eventType: 'payment_intent.succeeded',
        provider: 'stripe',
        idempotencyKey: 'evt_test_1'
    );

    $handler = new StripeWebhookHandler();
    $response = $handler->handle($payload);

    $this->assertEquals(200, $response->getStatusCode());
}

Integration Testing

public function test_signature_rejected()
{
    $request = new HttpRequest();
    $request->setBody('{"type":"payment_intent.succeeded"}');
    $request->setHeader('Stripe-Signature', 't=1,v1=invalid');

    $payload = WebhookPayload::fromHttpRequest($request, 'stripe');
    $response = resolve(WebhookManager::class)->process($payload);

    $this->assertEquals(401, $response->getStatusCode());
}

Logging & Observability

Logs are emitted under the webhook channel. Events logged:

Level Event
debug Signature verified, handler completed
info Duplicate webhook ignored, async queued
warning No handler matched
error Signature failure, handler exception

Search for provider, event, and key in log context to trace a specific webhook lifecycle.

Security Checklist

  • ✅ Always verify signatures – set verify_signature: true
  • ✅ Always use idempotency – most providers send a key; pass the correct header
  • ✅ Use HMAC-timestamp when available – prevents replay attacks
  • ✅ Rotate secrets – never commit secrets to source control
  • ✅ Rate limit your endpoint – use a reverse proxy or middleware
  • ✅ Log and alert on 401s – a spike usually means an attack
  • ✅ Respond within 5 seconds – use async: true for anything slower

Configuration Reference

Provider Configuration

'providers' => [
    'my_provider' => [

        // REQUIRED — shared secret for signature verification
        'secret' => getenv('MY_PROVIDER_SECRET'),

        // REQUIRED — processing mode
        //   false = run handlers in the HTTP request
        //   true  = return 202 and run handlers on the queue
        'async' => false,

        // OPTIONAL (default true) — set false to disable signature checks
        // (only useful in development; NEVER disable in production)
        'verify_signature' => true,

        // OPTIONAL — where to find the event type in the body
        // Accepts: ['type' => 'a.b.c'] or ['callback' => fn()]
        'event_resolver' => ['type' => 'type'],

        // OPTIONAL — full class name of the default handler
        'handler' => \App\Webhooks\Handlers\MyWebhookHandler::class,

        // OPTIONAL (default 'stop') — 'stop' or 'continue'
        'handler_failure_mode' => 'stop',

        // OPTIONAL — signature verification details
        'verify' => [
            // 'hmac', 'hmac_timestamp', or 'custom'
            'type'   => 'hmac',
            'header' => 'X-Signature',
            'algo'   => 'sha256',
            'prefix' => '',
            // 'tolerance' => 300,     // hmac_timestamp only
            // 'callback'  => fn(),    // custom only
        ],
    ],
],

Handler Response Codes

Status Meaning When to Return
200 OK Processed successfully Default for sync handlers
202 Accepted Acknowledged, processing async Automatic in async mode
400 Bad Request Invalid payload Malformed data
401 Unauthorized Signature failed Automatic
404 Not Found No handler matched Automatic when no handler found
500 Server Error Handler threw Automatic when handler_failure_mode: 'stop'

API Reference

WebhookManager

public function process(WebhookPayload $payload): WebhookResponse

Entry point. Verifies signature, checks idempotency, dispatches sync or async.

public function dispatchToHandlers(WebhookPayload $payload): WebhookResponse

Runs all matching handlers synchronously. Returns the first non-2xx, or the last response.


WebhookPayload

public static function fromHttpRequest(
    HttpRequest $request,
    string $provider,
    ?string $idempotencyHeader = 'X-Request-Id',
    ?array $providerConfig = null
): self
public function getRawBody(): string
public function getHeaders(): array
public function getParsedData(): ?array
public function getEventType(): string
public function getProvider(): string
public function getIdempotencyKey(): ?string
public function get(string $key, $default = null): mixed   // dot notation

WebhookResponse

public static function ok(?array $body = null): self
public static function accepted(): self
public static function badRequest(string $message = 'Bad request'): self
public static function unauthorized(string $message = 'Unauthorized'): self
public static function notFound(string $message = 'Endpoint not found'): self

public function getStatusCode(): int
public function getHeaders(): array
public function getBody(): ?array
public function toHttpResponse(): HttpResponse
public function toJson(): string

WebhookSignatureVerifier

public static function verifyHmac(
    string $payload,
    string $secret,
    string $header,
    string $algo = 'sha256',
    string $prefix = ''
): bool
public static function verifyHmacTimestamp(
    string $payload,
    string $secret,
    string $header,
    string $algo = 'sha256',
    string $prefix = '',
    int $toleranceSeconds = 300
): bool
public static function verifyCustom(
    string $payload,
    array $headers,
    callable $verifier,
    string $secret
): bool

WebhookSubscriptionManager

public function registerHandler(WebhookHandlerInterface $handler): void
public function getHandlersForEvent(string $event): array
public function getSecretForProvider(string $provider): ?string
public function getVerificationMethod(string $provider): array
public function isAsync(string $provider): bool
public function getEventResolver(string $provider): ?array
public function getHandlerFailureMode(string $provider): string
public function mustVerifySignature(string $provider): bool

IdempotencyStore (Interface)

public function lock(string $key, int $ttl = 3600): bool;
public function markDone(string $key): void;
public function isDone(string $key): bool;

WebhookHandlerInterface

public function handle(WebhookPayload $payload): WebhookResponse;
public function supportsEvent(): string|array;

Best Practices

1. Always Verify Signatures

Never trust an incoming webhook. Attackers can POST to your endpoint. Signature verification is non-negotiable in production.

'verify_signature' => true,

2. Always Use Idempotency

Even if the provider doesn't retry today, they will. Deduplicate on the provider's delivery ID.

3. Prefer Async for Anything Non-Trivial

Providers give you ~5 seconds. If your handler calls an API, sends an email, or writes to multiple services, use async: true.

4. Return Fast, Work Slow

// ❌ BAD — could timeout if provider is slow
public function handle(WebhookPayload $p): WebhookResponse
{
    $user = $this->http->get("https://api.example.com/users/{$p->get('user_id')}");
    $this->pdfGenerator->generate($user);
    return WebhookResponse::ok();
}

// ✅ GOOD — enqueue and return
'async' => true,

5. Be Defensive with Payload Data

$amount = $payload->get('data.object.amount', 0);
$customer = $payload->get('data.object.customer');

if ($customer === null) {
    $this->logger->warning('Missing customer in payload', ['event' => $event]);
    return WebhookResponse::ok(); // ack but don't process
}

6. Handle Unknown Events Gracefully

public function handle(WebhookPayload $payload): WebhookResponse
{
    match ($payload->getEventType()) {
        'payment.succeeded' => $this->onSucceeded($payload),
        'payment.failed'    => $this->onFailed($payload),
        default             => $this->logger->info('Unhandled event', ['event' => $payload->getEventType()]),
    };
    return WebhookResponse::ok();
}

7. Choose handler_failure_mode Deliberately

  • Use 'stop' when handlers are dependent on each other (e.g., fulfillment + email confirmation)
  • Use 'continue' when handlers are independent (e.g., analytics + audit log)

8. Monitor Your Webhooks

Add a health-check metric: count of 4xx vs 2xx over time. A sudden spike in 401s means either your secret was rotated (bad deployment) or someone is probing your endpoint.

9. Rotate Secrets Carefully

To rotate without downtime:

  1. Add the new secret alongside the old
  2. Update the provider to use the new secret
  3. Wait for all in-flight retries to drain (24h+)
  4. Remove the old secret

10. Test with Real Provider Payloads

Don't invent payloads — use the provider's documented examples or capture real ones (with secrets redacted).


Troubleshooting

Webhook returns 401 Unauthorized

Symptom: All requests rejected with "Invalid signature".

Causes & fixes:

  1. Wrong secret – check your .env and the provider's dashboard. They must match exactly (no trailing whitespace).
  2. Wrong header name – verify verify.header matches what the provider sends.
  3. Body was modified – some frameworks mutate the request body before you read it. Always verify against getRawBody().
  4. Wrong algo – Stripe uses sha256, some legacy providers use sha1.
  5. Wrong prefix – GitHub uses sha256=abc123. Set prefix: 'sha256=' so the prefix is stripped before comparison.
  6. Clock skew – for hmac_timestamp, if your server clock is off by more than tolerance, every request fails. Sync NTP.

Handler Never Runs

Symptom: 200 OK, but your code doesn't execute.

Checks:

  1. Is the handler registered?
    $manager->registerHandler(new MyHandler());
  2. Does supportsEvent() match?
    public function supportsEvent(): string|array { return ['payment.succeeded']; }
  3. Is the event resolving correctly?
    error_log($payload->getEventType());  // should be what you expect
  4. Is the queue worker running? (for async)
    php artisan queue:work --queue=webhooks

Duplicate Processing

Symptom: Same event processed twice.

Fixes:

  1. Is idempotencyKey null? Check the header name passed to fromHttpRequest.
  2. Is cache shared across workers? File-based cache on multi-server setups will fail. Use Redis.
  3. Is lock() returning false on retry but not on first call? Ensure markDone() is called after success.
  4. For async: duplicate jobs are only prevented by the cache lock, not by the queue. Ensure your cache backend is atomic.

Events Delivered But Nothing Happens

Symptom: Provider dashboard shows "delivered", no errors, but no business logic.

Check provider response in dashboard. Sometimes the provider thinks 200 is a fail (e.g., Stripe expects 2xx; a 302 redirect is treated as a delivery failure and retried).

Also check:

  • Are you returning WebhookResponse from the controller? (Not a plain string, not a redirect.)
  • Are you swallowing exceptions silently in your handler?

WebhookResponse::toJson() Errors

Symptom: Undefined property: statusTexts.

The HttpResponse class must define statusTexts. If your version doesn't, override toJson() in the controller:

$response = $manager->process($payload);
return json_encode([
    'status' => $response->getStatusCode(),
    'body'   => $response->getBody(),
]);

Queue Backlog

Symptom: webhooks queue growing unbounded.

Fixes:

  1. Scale workers: php artisan queue:work --queue=webhooks
  2. Split queues per provider: use 'queue' => "webhooks:{$provider}" in WebhookJob
  3. Reduce handler time: profile slow handlers
  4. Return 200 before dispatch: if handler is slow because of downstream services, consider a second-level queue

Signature Works in Dev, Fails in Prod

Common causes:

  • Different body encoding – some proxies re-encode JSON. Verify against raw bytes.
  • Different secret – .env not deployed, or the provider's "test" secret used in production.
  • Reverse proxy modifies body – some WAFs or body-inspection proxies rewrite whitespace. Disable for webhook paths.
  • HTTPS to HTTP rewrite – if the provider sends a signature over the URL, ensure the URL matches exactly.

Appendix: make:webhook Command Reference

php artisan make:webhook <Name> [options]

Arguments:
  name                  Provider name, e.g. Stripe, GitHub

Options:
  --controller          Generate the controller only
  --config              Add provider config only
  --handler             Generate the handler only
  --provider            Generate the service provider only
  --all                 Generate everything (default if no options)

Generated Files

File Purpose
app/Controllers/{Name}WebhookController.php HTTP entry point
app/Webhooks/Handlers/{Name}WebhookHandler.php Business logic
app/Providers/{Name}WebhookServiceProvider.php Handler registration
config/core/webhooks.php Provider configuration (merged)

Post-Generation Steps

The command prints reminders:

  1. Register the handler in the generated service provider
  2. Add the route
  3. Set the secret in .env

Summary

You now have everything needed to handle webhooks from any provider with confidence:

  • Secure – signature verification, replay protection
  • Reliable – idempotency, lock-based concurrency control
  • Scalable – queue-based async processing
  • Maintainable – clean interfaces, swappable backends
  • Observable – structured logs at every stage

Start with the make:webhook command, run it against the provider's CLI forwarder, and iterate. The component handles the hard parts — you focus on business logic.

Clone this wiki locally