Testing your integration
Webhooks::fake() records every webhook the package would send, for the rest of the test,
instead of sending it. Nothing is written to the delivery log and no job is queued:
use Pushery\Webhooks\Facades\Webhooks;
use Pushery\Webhooks\Testing\FakedWebhook;
use Pushery\Webhooks\WebhookEvent;
Webhooks::fake();
WebhookEvent::dispatch('invoice.paid', ['invoice_id' => 'in_123'], tenant: $team);
Webhooks::assertDispatched('invoice.paid', fn (FakedWebhook $webhook): bool => $webhook->payload['invoice_id'] === 'in_123');
Webhooks::assertDispatchedTimes('invoice.paid', 1);
Webhooks::assertNotDispatched('invoice.voided');
To prove that something sends nothing, assert exactly that after it:
Webhooks::fake();
$ticket->addInternalNote('Called the customer back.');
Webhooks::assertNothingDispatched();
The fake sees every way the package sends: dispatch(), dispatchTo(), ping() and
redeliver() on the facade or the manager, WebhookEvent::dispatch(), a PendingWebhook sent by
any of its dispatch methods, and DeliveryStreams::resend(). Each call is recorded once, as a
Testing\FakedWebhook:
| Property | Holds |
|---|---|
eventType | The event type, or null for a send through the Server layer that named none |
payload | The event data the call was given; for a send through the Server layer, its body decoded from JSON |
subscriptions | The endpoints a call to the manager was for: for dispatch(), every endpoint that listens, possibly none |
delivery | For a send through the Server layer, the delivery data it would have queued, with its URL, body and headers |
A call is recorded whether or not an endpoint listens, so a test needs no endpoint to see what
your code asked for. Webhooks::dispatched() returns the recorded webhooks, of one event type or
of all, and each sending method hands back the deliveries it would have logged, unsaved.
What runs before a send still runs under the fake: a payload its catalog schema refuses still
throws InvalidPayloadException, dispatchTo() still refuses an endpoint that does not listen,
and a test ping still counts against its allowance.
Asserting on the queue
Every delivery is a queued job, so Bus::fake() asserts the fan-out as well, as the queue sees
it:
use Illuminate\Support\Facades\Bus;
use Pushery\Webhooks\Server\Jobs\CallWebhookJob;
use Pushery\Webhooks\WebhookEvent;
Bus::fake();
WebhookEvent::dispatch('invoice.paid', ['invoice_id' => 'in_123'], tenant: $team);
Bus::assertDispatched(CallWebhookJob::class);
Use ->dispatchSync() on a PendingWebhook when you want the request to actually go out
(against a fake HTTP server, say), and drive the inbound side by posting a request signed with
Pushery\Webhooks\Core\Signing\StandardWebhooksScheme — the same class the sender uses, so a
first-party round trip is testable end to end.
Resolving endpoint hosts in your tests
The SSRF guard resolves the host of every endpoint URL and every JWKS URL before anything is
sent, and Http::fake() does not reach that lookup, because it happens before the transport is
asked. To keep your tests off public DNS, bind a Core\Ssrf\HostResolver that answers with an
address you choose:
use Pushery\Webhooks\Core\Ssrf\HostResolver;
$this->app->instance(HostResolver::class, new class implements HostResolver
{
/** @return list<string> */
public function resolve(string $host): array
{
return ['93.184.216.34'];
}
});
Binding it is enough, also when the guard has already been built, by a migration in a worker's
first test for example: the guard, the JWKS key set and the manager behind the Webhooks facade
are built again from the new resolver the next time they are asked for. Answer with a public
address, because the guard still classifies it and refuses a private one, as it would in
production.
Working on the package itself
Its own suite runs against a real PostgreSQL 18 database — deliberately stricter than the
PostgreSQL 13+ the package supports at runtime, so that a green local run proves what a
green CI run proves. The suite checks its own floor and fails rather than skipping if the
server is older. There is no SQLite fallback:
createdb webhooks_for_laravel_test, then point DB_HOST / DB_PORT / DB_DATABASE /
DB_USERNAME / DB_PASSWORD at it if it is not the default postgres user on the default
port.
The cross-engine parity suites additionally drive a real MySQL 8.4 and a MariaDB server (the MariaDB one proves the guard rejects it), so those run locally. See CONTRIBUTING.md.