Skip to main content

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:

PropertyHolds
eventTypeThe event type, or null for a send through the Server layer that named none
payloadThe event data the call was given; for a send through the Server layer, its body decoded from JSON
subscriptionsThe endpoints a call to the manager was for: for dispatch(), every endpoint that listens, possibly none
deliveryFor 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.