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 GIN. | Only if you search inside stored payloads. | No shipped query uses it — nothing breaks. Search deliveries/calls with Scout instead — 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.
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 and accents, 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.
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.
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'];