Coming from another webhook package
This package is a superset of the usual pair — one package that sends, one that receives — on
one engine or the other, including MySQL, which is where most of those apps run. A send-only
package needs no database at all, and a receive-only one ships a flat, engine-agnostic
webhook_calls table. You do not have to switch databases to adopt this.
Replacing the sender
Replace the outbound builder with Pushery\Webhooks\Server\PendingWebhook (or the
WebhookSender facade). Signing is Standard Webhooks by
default; if your consumers verify an older Signature-header HMAC, keep sending that by setting
the scheme per call.
Backoff, Retry-After, per-call timeouts, SSRF pinning and mutual TLS are all built in — see
Sending. No table to migrate: sending is stateless.
Replacing the receiver
Replace the route and its processing job with Route::webhooks('your/path', 'source-name') and a
config profile — see Receiving.
Ours verifies, de-duplicates on receipt (two-tier), redacts headers, offloads over-sized
bodies and keeps the exact received bytes, so
hash('sha256', $call->body()) === $call->body_sha256.
Our webhook_calls is a superset of the usual shape, and one difference is worth naming because
it decides cost rather than behavior: created_at carries its own index
(webhook_calls_created_idx), so retention pruning stays cheap as the log grows.
Backfilling the old rows
New receipts flow into this package's webhook_calls from the moment you switch the route over —
nothing else is required to go live. Carrying the old rows across is a one-time copy, and
php artisan webhooks:import-calls does it.
It reads the shape you declare
The importer does not assume a schema. Five options name the columns to read, and their defaults describe the shape these tables almost always have:
| Option | Default | What it reads |
|---|---|---|
--from-id | id | each row's primary key — what the derived, idempotent target id is built from |
--from-source | name | which producer the row came from. Empty imports as default |
--from-payload | payload | the decoded JSON body |
--from-headers | headers | the request headers as JSON. Missing or empty imports as no headers |
--from-error | exception | the failure record. Its presence marks the imported row failed |
A table that spells them differently needs no code, only options:
php artisan webhooks:import-calls \
--from-table=inbound_hooks --from-source=provider --from-error=last_error --dry-run
Point it at the source with --from-table and --from-connection — a source table is often also
named webhook_calls, so a same-database import needs one of these to name it distinctly. Preview
the counts with --dry-run.
Run it as often as you like
Each imported row's primary key is derived deterministically from (source, source row id), so a
second run re-derives the same ids and imports nothing new.
--sourceThe key is (source, row id). Import two tables without distinguishing them and rows whose
sources agree and whose ids overlap collapse onto each other — the second import reports the
collisions as "already present" and those rows are simply not there. That is what --source is
for; pass it once per import and the two can never meet.
Two caveats, both forced by what such a table stores
It keeps only the parsed payload, never the raw received bytes, so an imported row cannot carry
the producer's original body_sha256. The command reconstructs a self-consistent one from the
re-encoded payload — verifiable against itself, but a reconstruction rather than the wire bytes.
Every row is written in a terminal state (processed, or failed where the source recorded
an error), and no handler is dispatched over months-old history. The error column is read for that
decision and its text is not carried: this log has no column for it, and copying an unbounded,
unredactable stack trace out of somebody else's table into this one is not something the import
should do quietly. Keep the source table until you are sure you no longer need those traces.
Treat imported rows as historical records, not re-verifiable ones.
The derived key changed in 3.0, so ids from an earlier import no longer re-derive. Running the new command over the same source table would import all of it a second time — the old rows are indistinguishable from ones this package received itself, so nothing can detect it for you.
--dry-run shows it before any write: over a backlog you already imported it must report
everything as already present. If it reports rows to import, this is what you are looking at, and
the answer is not to run it.