Choosing your database
Reach for a topology in this order — lead with what your app already has, not with an engine demand:
| Topology | When | What you run |
|---|---|---|
| T1 — Send-only | You only send webhooks | No database. Platform off; the Server engine needs only a queue. |
| T2 — Same database | You persist, on the engine your app already uses | PostgreSQL or MySQL 8.4+ — the package migrates its tables into your app's own connection. |
| T3 — Side-car | You persist, but want the webhook tables elsewhere | Point webhooks.database.connection at a dedicated connection — e.g. a MySQL app with a PostgreSQL side-car. See A dedicated database connection. |
The one-line recommendation: don't switch engines for this package — use the one your app
already runs on. PostgreSQL is the reference engine and keeps a few storage accelerations
MySQL can't express, but every guarantee the package makes holds identically on both —
exact percentile numbers, race-free dedupe, the body_sha256 byte-fidelity promise, the
DB-enforced GDPR-erasure cascade, DST-safe timestamps, and case-sensitive identity. MySQL
users give up storage optimizations, never correctness.
What PostgreSQL buys you
Every row below is a PostgreSQL-only storage optimization. None of them changes a result — they change cost at scale. Read the recommendation as "this is when, and only when, the difference is worth an engine."
| Difference (PostgreSQL only) | When it hits you | Tip | Recommendation |
|---|---|---|---|
O(1) retention — PG drops an old month as a partition; MySQL runs an indexed, chunked DELETE. | Invisible below ~1M deliveries/month; real IO pressure at tens of millions. | Lower platform.retention_months, enable payload offload, run webhooks:partition-maintenance off-peak. | Above ~1M deliveries/month and long retention → PostgreSQL. Otherwise MySQL is fine. |
| Index bounded by the open backlog — PG partial indexes; MySQL indexes all history. | Same volume threshold. | Nothing to configure — the composite indexes are used natively on both. | Not worth an engine on its own; folds into the retention call above. |
Indexed containment search into an inbound payload — PG jsonb takes a GIN index, and the package builds none. | Only if you search inside stored payloads. | No shipped query searches there, so no index is built for it. Add one in a migration of your own (CREATE INDEX webhook_calls_payload_gin ON webhook_calls USING gin (payload jsonb_path_ops)), or search deliveries/calls with Scout — opt-in, and it takes three steps: see Searching the logs. | Not an engine-choice factor. |
The tdigest percentile tier — an optional PG extension. | Only at very high dashboard volume, and only if you set dashboard.percentiles.driver = 'tdigest'. | The default live driver is the same speed and returns identical numbers on both engines. | Not a reason to pick PostgreSQL — and tdigest isn't on Neon (Laravel Cloud's Postgres) either, so this tier is unavailable there regardless. |
max_allowed_packet — MySQL defaults to 64 MB vs PG's ~1 GB. | Only with multi-MB single events. | Enable offload (server.large_payload.enabled = true, and the inbound offload) so a big body never reaches the column; reclaim the offloaded objects with a disk lifecycle policy, or php artisan webhooks:prune-orphaned-payloads (retention prunes rows only — see config/webhooks.php). | Enable offload on MySQL if you emit multi-MB events. |
One hard warning — read this if you run MySQL
The package declares utf8mb4_0900_as_cs (case- and accent-sensitive) on every identity
column, on purpose. Do not ALTER it back to a _ci collation. Under a case-insensitive
collation evt_AbC and evt_abc collapse into one dedupe row, and a distinct,
signature-verified webhook is answered 200 and silently discarded. A binary collation is
not automatically safe either: the older ones such as utf8mb4_bin pad with spaces, so an id
and the same id with a trailing space collapse the same way. utf8mb4_0900_bin does not pad.
php artisan webhooks:preflight checks this and fails when it finds it. It asks MySQL which
columns the package's tables carry a UNIQUE index on — the columns where a collation stops being
cosmetic and starts deciding whether two rows are the same row — and refuses any that no longer
distinguish case, accents or trailing spaces, naming the table, the column, the index and the
ALTER that puts it back. The set comes from the live schema rather than a list in the package, so a unique index
added later is covered the day it exists.
A manual ALTER can still undo the schema between two preflights; what it can no longer do is
survive one.
One endpoint's event types, on MySQL
MySQL indexes webhook_subscriptions.event_types with a multi-valued index, and that index refuses
a row whose values run past about 5,344 bytes in total (error 3906). So on MySQL the self-service
portal and the operator console take at most 5,000 bytes of event types for one endpoint,
counted in UTF-8 with a repeated type counted each time, and answer a longer selection with a field
error. A catalog of 120 types of 45 bytes each already goes past it when a customer selects them
all. An endpoint your code registered with more keeps its types through an edit, but can't grow.
PostgreSQL has no such bound, and there the forms don't apply one.
The bound lives in the two forms. Webhooks::subscribe() called from your own code isn't checked
against it, and on MySQL a longer list ends in the database's own error.
MariaDB is not supported
MariaDB is not supported, at any tier — it is rejected loudly at migrate time and by
webhooks:preflight. Its JSON is a LONGTEXT alias, and it has neither the multi-valued
index the fan-out lookup needs nor functional indexes. Use MySQL 8.4+ or PostgreSQL.
Nor is a server that only speaks PostgreSQL's protocol. CockroachDB, YugabyteDB and engines like
them report a version the PostgreSQL floor accepts, so the package also reads the server's
version() banner and refuses one that is not PostgreSQL's, at migrate time and in
webhooks:preflight, naming what the server reported.
migrate --pretend executes nothing and asks the server nothing either, so both refusals come from
the run that executes the migrations, and from webhooks:preflight at any time.
Deploying to Laravel Cloud
Laravel Cloud offers both a first-party MySQL 8.4 database and Neon-powered Serverless Postgres — either works. Two cautions:
- Run migrations against your database's direct, non-pooled endpoint.
migrateandwebhooks:partition-maintenanceissue transactional DDL, which a transaction pooler (PgBouncer, Neon's pooled endpoint) breaks. - Neon does not ship the
tdigestextension, sodashboard.percentiles.driver = 'tdigest'is unavailable there — the defaultlivedriver needs no extension and returns the same numbers.
A dedicated database connection
By default the package migrates its tables into your app's default connection. To keep
them on a different one — the headline case being a MySQL app with a PostgreSQL side-car
for the webhook tables (topology T3) — set webhooks.database.connection to a connection
defined in config/database.php:
WEBHOOKS_DB_CONNECTION=webhooks_pgsql
Every model, migration and analytics query then resolves that one connection, so the package
never silently splits across two databases; leave it unset and everything stays on the app
default. php artisan webhooks:preflight prints the connection it resolved. When you test a
host app on this topology, transact both connections so each case rolls back:
protected $connectionsToTransact = ['mysql', 'webhooks_pgsql'];