Querying the tables yourself
If you write your own health check or report against the package's tables —
WebhookDelivery, WebhookCall, WebhookServerDelivery — bind timestamps through the
shipped scopes, never a plain ->where('created_at', …).
Every timestamp column is timestamptz (PostgreSQL) or a UTC-naive DATETIME(6) (MySQL); a
naive literal is resolved against the database session time zone, which is unrelated to
app.timezone and routinely not UTC — so a hand-bound comparison is off by that offset and
quietly returns the wrong rows, with no error to notice. A health check written that way can
never fire.
The scopes bind the instant, per dialect, so you can't get it wrong:
use Pushery\Webhooks\Models\WebhookDelivery;
// "Are deliveries piling up undelivered because no worker is running?"
$stuck = WebhookDelivery::query()
->pendingSince(now()->subMinutes(15))
->count();
WebhookDelivery::query()->createdBefore(now()->subDay()); // strictly before
WebhookDelivery::query()->createdAfter($since); // at or after
WebhookDelivery::query()->createdBetween($from, $to); // half-open [from, to)
WebhookDelivery::query()->whereTimestamp('delivered_at', '>=', $since); // any column
For a raw statement the scopes don't cover, (new WebhookDelivery)->boundTimestamp($moment)
returns the same offset-correct literal to bind by hand.
A raw format constant is deliberately not exposed: the correct literal differs by engine, so a copied constant would be right on one and wrong on the other — only a dialect-aware binding is safe.
Writing rows yourself
Reading is not the only direction that can go quietly wrong. If you insert into these tables
directly — a demo seeder, a fixture, a backfill of your own — write through the models
rather than a raw INSERT. Three columns of webhook_deliveries refuse a hand-written row,
and one of them refuses it on only one of the two engines:
| What the insert did | PostgreSQL 18 | MySQL 8.4 |
|---|---|---|
put a non-UUID in event_id (a ULID, say) | 22P02 — rejected | accepted, stored verbatim |
supplied a value for payload_type | 428C9 — rejected | 3105 — rejected |
omitted id | 23502 — rejected | 1364 — rejected |
The last two rows are loud on both engines, so they cost you a red run and nothing else.
payload_type is a STORED generated column mirroring the payload's own type field — it is
computed, never written. id has no database-side default anywhere: the model supplies a
UUIDv7 through Laravel's HasUuids, and a raw insert has to supply one too.
The first row is the one worth remembering, because it is silent on exactly one engine.
event_id is a real uuid on PostgreSQL and a char(36) on MySQL, so a 26-character ULID
overflows nothing and is stored as-is. That value is not private bookkeeping: it is sent as
the Standard Webhooks webhook-id header, and a redelivery re-signs with the same id
precisely so the receiver can recognize it. A row written with a non-UUID id therefore
carries that id out to the endpoint — where the two engines now disagree about what your
system even emits.
So the rule is the same one the read side arrives at from the other direction: let the
package mint the value. Dispatching through Webhooks::dispatch() generates the event id as
a UUIDv7 itself, and the model fills id and leaves payload_type alone. A row you write by
hand has to be exactly as strict about event_id — on the engine that would have told you,
and on the one that would not.