Skip to main content

Choosing your database

Reach for a topology in this order — lead with what your app already has, not with an engine demand:

TopologyWhenWhat you run
T1 — Send-onlyYou only send webhooksNo database. Platform off; the Server engine needs only a queue.
T2 — Same databaseYou persist, on the engine your app already usesPostgreSQL or MySQL 8.4+ — the package migrates its tables into your app's own connection.
T3 — Side-carYou persist, but want the webhook tables elsewherePoint 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 youTipRecommendation
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:

  1. Run migrations against your database's direct, non-pooled endpoint. migrate and webhooks:partition-maintenance issue transactional DDL, which a transaction pooler (PgBouncer, Neon's pooled endpoint) breaks.
  2. Neon does not ship the tdigest extension, so dashboard.percentiles.driver = 'tdigest' is unavailable there — the default live driver 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'];