---
title: "Testing — Webhooks for Laravel | Roundly"
description: "Webhooks::fake() with every assertion and its negative, SignedWebhookRequest for inbound tests in any scheme, factories and FakeHostResolver for DNS."
url: https://roundly-consulting.com/open-source/docs/webhooks-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Webhooks for Laravel](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)

# Testing

## Webhooks::fake()

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

```php
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 call
```

It 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.

```php
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()` | — |

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

```php
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](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel/testing.md) 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:

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

```php
Webhooks::schemes()->forget('github');
Webhooks::handlers()->forget('billing');
```

Under [Sentinel::fake()](https://roundly-consulting.com/open-source/docs/sentinel-for-laravel/testing.md), 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)
