Webhooks::fake()
use RoundlyConsulting\Webhooks\Facades\Webhooks;
use RoundlyConsulting\Webhooks\Testing\SentWebhook;
$fake = Webhooks::fake();
$sendInvoiceWebhook($invoice); // your code calls Webhooks::send() / broadcast() / …
$fake->assertSent($endpoint, 'invoice.paid', fn (array $payload): bool => $payload['invoice'] === 42);
$fake->assertSent('invoice.paid', payload: fn (array $payload): bool => $payload['invoice'] === 42); // without the endpoint
$fake->assertSent(fn (SentWebhook $sent): bool => $sent->endpoint->is($endpoint) && ! $sent->broadcast);
$fake->assertSentTimes(1, 'invoice.paid');
$fake->assertNotSent($otherEndpoint);
$fake->assertBroadcast('invoice.paid', $team, fn (array $payload): bool => $payload['invoice'] === 42);
$fake->assertBroadcastTimes(1, 'invoice.paid');
$fake->assertNotBroadcast('invoice.void');Every recorded verb has an assertion and a negative:
| Verb | Assert | Negatives | Collection |
|---|---|---|---|
send() | assertSent($endpoint|$type|Closure, ?$type, ?$payload), assertSentTimes($n, …) | assertNotSent(…), assertNothingSent() | sent() — SentWebhook: endpoint, eventType, payload, body, broadcast, owner, webhookId |
broadcast() | assertBroadcast($type, ?$owner, ?$payload), assertBroadcastTimes($n, $type, ?$owner, ?$payload) | assertNotBroadcast($type, ?$owner), assertNothingBroadcast() | broadcasts() — RecordedBroadcast: eventType, owner, payload |
redeliver() · redeliverFailed() | assertRedelivered($delivery|$endpoint) | assertNothingRedelivered() | redelivered() |
replay() | assertReplayed($delivery) | assertNothingReplayed() | replayed() |
rotateSecret() | assertSecretRotated($endpoint) | assertSecretNotRotated($endpoint), assertNoSecretsRotated() | — |
disable() | assertEndpointDisabled($endpoint) | assertEndpointNotDisabled($endpoint), assertNoEndpointsDisabled() | — |
- send(), broadcast(), ping(), redeliver(), redeliverFailed() and replay() validate exactly like production — disabled or unsubscribed endpoints, bad event types, oversized payloads, in-flight or pruned deliveries throw the same exceptions — but queue nothing, send nothing and write no delivery.
- A faked send() returns an unsaved OutboundDelivery: exists is false and getKey() is null — compare its webhookId() (msg_…). redeliverFailed() returns the counts production would queue and skip.
- Endpoint management, receive() and verify() run for real; secret rotations and disable() run for real and are recorded. dispatchDue() — and so webhooks:retry — dispatches nothing and returns 0.
- A broadcast also records a SentWebhook per endpoint that takes it (broadcast: true); a ping() records a send of webhook.ping; a repeat of an idempotent send is not recorded again.
- The fake extends the manager, so an injected WebhooksManager is the fake too, and it sees calls through inbound(), outbound(), for($owner), the HasWebhooks trait and BroadcastsAsWebhook events. It survives a scope reset, so a test that runs a real queue:work still talks to the same fake.
SignedWebhookRequest — inbound requests for your tests
use RoundlyConsulting\Webhooks\Testing\SignedWebhookRequest;
SignedWebhookRequest::for($endpoint)
->payload(['type' => 'invoice.paid', 'data' => ['id' => 42]]) // or ->body('exact bytes')
->send() // through the HTTP kernel: TestResponse
->assertAccepted();
SignedWebhookRequest::for($endpoint)->tamper()->send()->assertUnauthorized();
SignedWebhookRequest::for($endpoint)->staleBy(600)->send()->assertUnauthorized();
SignedWebhookRequest::for($endpoint)->unsigned()->send()->assertUnauthorized(); // missing_signature
SignedWebhookRequest::for($endpoint)->withoutHeader('webhook-timestamp')->send()->assertUnauthorized();
SignedWebhookRequest::for($endpoint)->idempotencyKey('order-7')->send();
SignedWebhookRequest::for($rotatedEndpoint)->signedWithPrevious()->send(); // rotation grace
SignedWebhookRequest::for($endpoint)->id('msg_1')->at($instant)->secret('whsec_…')->withHeader('X-A', 'b');
SignedWebhookRequest::for($rfc9421Endpoint)->keyId('partner')->send(); // signed by sentinel
$data = SignedWebhookRequest::for($endpoint)->toRequest(); // SignedRequestData: uri, headers, body
$this->call('POST', $data->uri, [], [], [], $data->server(), $data->body); // your own callIt signs with the endpoint’s own scheme: the default scheme, any scheme that implements SignsOutboundWebhooks (the github and stripe recipes do), or — for a scheme without sign() — the closure you give. Otherwise toRequest() throws a LogicException naming the scheme; it never sends headers the scheme would not accept.
SignedWebhookRequest::for($endpoint)
->signUsing(fn (OutgoingWebhook $message, EndpointSecrets $secrets): SignatureHeaders => new SignatureHeaders([/* … */]))
->send();tamper() flips a signature byte for the default and RFC 9421 schemes and changes the signed body for any other. signedWithPrevious() on an endpoint with no previous secret throws a LogicException, as does an RFC 9421 endpoint with neither keyId() nor scheme_options.key_id.
Factories
Every packaged model has one, and each builds the configured model class, so a swapped model gets its own instances:
| Factory | States |
|---|---|
InboundEndpoint::factory() | withSecret(string $secret), disabled(), scheme(string $scheme), handler(string $alias), ownedBy(Model $owner) |
OutboundEndpoint::factory() | url(string $url), subscribedTo(array $types), withSecret(string $secret), withHeaders(array $headers), disabled(), ownedBy(Model $owner) |
OutboundDelivery::factory() | status(OutboundStatus $status) |
InboundDelivery::factory() · OutboundAttempt::factory() | — |
$endpoint = InboundEndpoint::factory()->handler('billing')->ownedBy($team)->create();
$outbound = OutboundEndpoint::factory()->url('https://hooks.example.com/in')->subscribedTo(['invoice.*'])->create();
$failed = OutboundDelivery::factory()->status(OutboundStatus::Failed)->create();A factory-made outbound endpoint skips the SSRF check on save — its URL is resolved only when an attempt runs. Answer those lookups with FakeHostResolver.
DNS for outbound tests
use RoundlyConsulting\Webhooks\Testing\FakeHostResolver;
$dns = FakeHostResolver::map(['hooks.example.com' => '93.184.216.34'])->install(); // = SafeFetch::fakeDns()
Http::fake(['*' => Http::response('ok')]);FakeHostResolver::map(array $records) takes host ⇒ one address or a list (A and AAAA); install() returns Safe Fetch’s fake resolver with its assertions (assertLookedUp($host, $times)). A name the table was not given does not resolve, so a test never reaches real DNS. Laravel hands an Http::fake() stub the request options, so a test can see the pin:
Http::fake(fn (Request $request, array $options) => Http::response($options['curl'][CURLOPT_RESOLVE][0]));Registries and Sentinel’s fake
Schemes and handlers registered at run time last for the process; remove one in a test (a configured one stays):
Webhooks::schemes()->forget('github');
Webhooks::handlers()->forget('billing');Under Sentinel::fake(), inbound verification and outbound signing record verifyWithSecrets and signWithSecret — never the secret — and compute the real result; dedupe behaves as Sentinel’s fake does.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.