Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Webhook Laravel Package

symfony/webhook

Part of the Symfony ecosystem, symfony/webhook eases both sending and consuming webhooks in PHP applications. It provides building blocks for dispatching outgoing webhooks and validating incoming ones, integrating cleanly with Symfony's event and HTTP components.

View on GitHub
Deep Wiki
Context7

Getting Started

symfony/webhook is a low-level Symfony component with two independent halves, and it ships with no Laravel bindings at all — no service provider, no config file, no facade. You wire it yourself, which is the entire integration cost. Knowing which half you need saves a lot of reading:

  • Inbound (consuming): Client\RequestParserInterface → turns an HTTP request into a RemoteEvent, with Client\AbstractRequestParser as an optional base class. Errors are signalled with Exception\RejectWebhookException (which carries an HTTP status code).
  • Outbound (sending): Server\TransportInterface / Server\AbstractTransport, plus PayloadSerializerInterface and HeaderProviderInterface extension points. Returns a symfony/contracts ResponseInterface.

Both halves meet at Symfony\Component\Webhook\RemoteEvent (a name + a payload array).

Install

composer require symfony/webhook

Pin the major deliberately: 8.x requires PHP >= 8.4, so most Laravel 11/12 apps on 8.2/8.3 want ^7.4. If you are on 8.x, require ^8.1.7 — earlier 8.1.x patches let a malformed JSON body escape as a 500 (see Gotchas). There is nothing to publish, migrate, or register — but you'll probably also want symfony/http-client if you plan to use the outbound transport.

Where to look first: the component itself is ~15 files. Read RemoteEvent.php, Client/RequestParserInterface.php, Server/AbstractTransport.php before the docs page — the docs are thin and the source is the real spec. Then check method signatures against your installed version: parse() gained a string $type argument in 7.2 and parsers can return multiple events since then.

First use case — a single inbound endpoint that verifies a signature and hands off to a queue:

// app/Webhooks/GitHubRequestParser.php
namespace App\Webhooks;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Webhook\Client\RequestParserInterface;
use Symfony\Component\Webhook\Exception\RejectWebhookException;
use Symfony\Component\Webhook\RemoteEvent;

final class GitHubRequestParser implements RequestParserInterface
{
    public function parse(Request $request, string $type): RemoteEvent
    {
        $body = $request->getContent(); // raw body — never re-encode
        $expected = 'sha256='.hash_hmac('sha256', $body, config('services.github.secret'));

        if (!hash_equals($expected, (string) $request->headers->get('X-Hub-Signature-256'))) {
            throw new RejectWebhookException(406, 'Invalid signature.');
        }

        try {
            $payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            // 406, not 500 — a bad body is the caller's fault, and a 500 would trigger retries
            throw new RejectWebhookException(406, 'Malformed request body.', $e);
        }

        return new RemoteEvent(
            name: 'github.'.$request->headers->get('X-GitHub-Event', 'unknown'),
            payload: $payload,
        );
    }
}
// routes/webhooks.php  (excluded from CSRF — see Gotchas)
Route::post('/webhooks/{type}', function (Request $request, string $type) {
    try {
        $event = app(WebhookRegistry::class)->parser($type)->parse($request, $type);
    } catch (RejectWebhookException $e) {
        return response($e->getMessage(), $e->getStatusCode());
    }

    ProcessWebhook::dispatch($event); // return fast, work later

    return response()->noContent();
})->middleware('api');

Illuminate\Http\Request extends Symfony\Component\HttpFoundation\Request, so it drops straight into the parser — no bridging needed.

Implementation Patterns

One parser per provider, behind a tiny registry. Third-party formats (Stripe, GitHub, Slack) share nothing but the interface, so don't try to build a universal parser. A match or a tagged-container binding keyed by the {type} route segment is enough:

final class WebhookRegistry
{
    public function parser(string $type): RequestParserInterface
    {
        return app(match ($type) {
            'stripe' => StripeRequestParser::class,
            'github' => GitHubRequestParser::class,
            default  => throw new NotFoundHttpException(),
        });
    }
}

Do signature verification inside doParse(), yourself. The component's default scheme is designed for Symfony-to-Symfony traffic, not for Stripe's Stripe-Signature or GitHub's X-Hub-Signature-256. Every real integration overrides this. Compute the HMAC over getContent(), compare with hash_equals(), and enforce a timestamp tolerance where the provider sends one (Stripe's t= is the classic case — replay protection is on you).

Extend AbstractRequestParser for the free JSON guard. Since 8.1.7 the base class decodes the body itself and throws RejectWebhookException(406) on invalid JSON instead of letting a JsonException bubble up as a 500. If you implement the raw interface (as above) you own that mapping — either way, never let a decode failure surface as a 5xx.

Treat RemoteEvent as the boundary, not the model. Convert it into a domain event/command immediately and keep third-party payload shapes out of your domain layer. This is also what makes multi-provider support survivable when a vendor changes their schema.

Return 2xx fast, then queue. Every major provider retries on timeout, so processing in-request is how you get duplicate side effects. Parse → dispatch a queued job → 204.

Idempotency is your table, not the package's. A webhook_events table with a unique index on (provider, event_id) and an insertOrIgnore() before dispatch is the whole solution, and it's cheaper than the framework you'd otherwise reach for.

Outbound: use the transport for envelope consistency, not for delivery guarantees. A custom PayloadSerializerInterface gives you a stable envelope (id, type, created_at, data) across all your outbound hooks, and ConfigurableHeaderProvider handles static headers/signature rotation:

$transport = new \Symfony\Component\Webhook\Server\Transport(
    HttpClient::create(), $requestFactory, $serializer, $headerProvider,
);

$transport->send(new RemoteEvent('order.shipped', $order->toArray()), $endpointUrl);

Wrap each send() in a queued job with Laravel's $tries / backoff() — that's where retries actually live.

If you'd rather not add symfony/http-client, implement TransportInterface directly and call Http::withHeaders(...)->post(...) inside it. AbstractTransport is more convenient but hard-couples you to the Symfony HTTP client contracts (including its ResponseInterface return type), so the direct-interface route is the pragmatic choice for teams already standardised on Http::fake() in tests.

Testing a parser is trivial and needs no base class: build Request::create('/webhooks/stripe', 'POST', [], [], [], $server, $rawBody), call parse(), assert on getName()/getPayload(); assert RejectWebhookException for bad signatures and for a truncated/garbage body (expect 406). If you use the outbound transport, MockHttpClient is the seam.

Gotchas and Tips

  • CSRF will silently 419 every webhook. Add webhooks/* to VerifyCsrfToken::$except (or keep the routes in a stateless api group). This is the single most common first-day failure.
  • Type-hint Symfony\Component\HttpFoundation\Request, never Illuminate\Http\Request, in a parser. Laravel's subclass satisfies the interface, but your own class method signature won't be compatible if you narrow it.
  • Never re-encode the payload before verifying. $request->all() / $request->json() round-trip through json_decode/json_encode and will break signatures on any payload with unicode escaping, float formatting, or key ordering differences. Always getContent().
  • Malformed bodies were a 500 until 8.1.7. Recent releases (bug #65772, in 8.1.7) make the component reject an undecodable body with 406 rather than letting the JsonException become a server error — which matters because a 500 tells every provider to retry a request that will never succeed. On older pins, or with a hand-rolled RequestParserInterface, wrap the decode yourself. Verify the fix is present: composer show symfony/webhook.
  • No retries, no backoff, no idempotency, no queueing are built in. The component does exactly one HTTP call in send(). Several write-ups about this package claim otherwise — verify against the source before you build a design on it.
  • Choose your status codes for retry semantics. 4xx on bad signature and on malformed JSON (stop), 5xx or 429 on transient failure (retry). Note that some providers retry on any non-2xx, so don't return 500 for a genuinely malformed-but-authentic event — that's precisely the class of bug 8.1.7 fixed upstream.
  • Secret rotation fights the interface. getSecret() returns one string; during a rotation window accept both old and new by doing the comparison logic inside doParse() rather than relying on a single-secret helper.
  • Version pinning matters more here than usual. 8.x is PHP 8.4+, and the parse() signature changed in 7.2. If you copy a snippet from the docs and get "declaration must be compatible", it's a version mismatch, not a bug.
  • Debugging: RejectWebhookException messages are visible in the provider's delivery log. Log the real reason locally (Log::withContext(['provider' => $type, 'event' => $id])) and return a generic body — don't leak expected-vs-actual signature details to the caller.
  • Issues and PRs go to symfony/symfony, not this repo. The split repo's README is a stub; the source of truth is the monorepo and the Symfony docs page. Point releases like 8.1.7 are Symfony's normal synchronized-release train, not a sign of churn — but with zero dependents there are no Laravel blog posts to crib from, so budget time to read the ~15 source files.
  • Extension points worth knowing: RequestParserInterface (inbound), TransportInterface (outbound transport swap), PayloadSerializerInterface (envelope format), HeaderProviderInterface (dynamic headers/signatures). Those four cover most customisation without forking.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo