tmonier / sylius-webhook-outbox-plugin
Transactional outbox for outgoing Sylius 2 webhooks: atomic event capture, Standard Webhooks signatures, retries, dead letters and replay from the admin panel
Package info
github.com/thibmonier/sylius-webhook-outbox-plugin
Type:sylius-plugin
pkg:composer/tmonier/sylius-webhook-outbox-plugin
Requires
- php: ^8.2
- sylius/sylius: ^2.2
- symfony/clock: ^6.4 || ^7.4 || ^8.0
- symfony/http-client: ^6.4.15 || ^7.4 || ^8.0
- symfony/http-foundation: ^6.4.15 || ^7.4 || ^8.0
- symfony/uid: ^6.4 || ^7.4 || ^8.0
Requires (Dev)
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-webmozart-assert: ^2.0
- phpunit/phpunit: ^10.5
- sylius-labs/coding-standard: ^4.4
- sylius/test-application: ^2.0.0@alpha
- symfony/browser-kit: ^6.4 || ^7.4 || ^8.0
- symfony/css-selector: ^6.4 || ^7.4 || ^8.0
- symfony/debug-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/dom-crawler: ^6.4 || ^7.4 || ^8.0
- symfony/dotenv: ^6.4 || ^7.4 || ^8.0
- symfony/intl: ^6.4 || ^7.4 || ^8.0
- symfony/runtime: ^6.4 || ^7.4 || ^8.0
- symfony/web-profiler-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/webpack-encore-bundle: ^2.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-12 06:24:52 UTC
README
Outgoing webhooks for Sylius 2 that are never lost: a transactional outbox, Standard Webhooks signatures, retries, dead letters and replay from the admin panel.
Your ERP, CRM or iPaaS wants to know when an order is placed, paid, shipped or cancelled, when a customer signs up, when stock moves. Sylius 2 has no outgoing webhooks, and the usual "dispatch an HTTP call in a listener" loses events when the call fails, sends events for changes that were rolled back, and leaves no trace.
This plugin writes each event in the same database transaction as the change that caused it (the outbox), then a worker delivers it with a signed HTTP request, retries for about three days, parks what still fails in a dead-letter list and lets an administrator replay it. It is free and open source.
Features
| Need | What the plugin does |
|---|---|
| Never lose an event, never send a phantom one | Events are captured from the Doctrine unit of work and inserted in the transaction of the business change: committed if and only if the order, payment or customer is. Works whatever the entry point (shop, API, admin, CLI). |
| Know what happened in the shop | order.placed, order.paid, order.cancelled, order.shipped, customer.created, customer.updated (whitelisted fields only), product_variant.stock_changed (one event per flush, not per order line), webhook.ping. |
| Stable, documented payloads | A versioned envelope {id, type, version, timestamp, channel, data}; data comes from the plugin's own normalizers (no API Platform dump), never contains passwords, tokens or the order token. JSON Schemas included. |
| Trust the sender | Standard Webhooks v1 signatures (webhook-id, webhook-timestamp, webhook-signature: v1,<base64>, whsec_ secrets), tested against the vectors of the specification. Receiver snippets for PHP and Node below. |
| Survive receiver outages | Database-driven retries: immediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h, 24 h (+ jitter). Retry-After honoured on 429/502/503/504 (all HTTP-date formats). 410 Gone disables the endpoint and suspends its other deliveries. After the last attempt (about 3 days and 4 hours after the event) the delivery is dead (dead-letter list). |
| Operate it | Integrations › Webhook endpoints / Webhook deliveries / Dead letters in the admin: endpoint CRUD (secret shown once, regenerate, ping), deliveries grid with filters, delivery page (request body, truncated response, last 10 attempts), Retry now, Replay (single and bulk, same webhook-id). |
| Stay safe | Dedicated HTTP client: private and link-local networks blocked after DNS resolution (NoPrivateNetworkHttpClient), no redirects, https only, 15 s idle timeout, 30 s maximum duration. Secrets never logged nor shown again. |
| Run it anywhere | tmonier:webhook:dispatch works from cron (--time-limit) or as a long-running worker (--loop); claims use SELECT … FOR UPDATE SKIP LOCKED and a lease, so a crashed worker's delivery is picked up again. |
| Respect the GDPR | tmonier:webhook:purge (retention), tmonier:webhook:anonymize-customer (erasure of payloads and response excerpts). |
| Add your own events | Tagged event type registry and a public recorder API: an RFQ plugin can publish quote.accepted, an e-invoicing plugin invoice.issued. |
Also included: translations in English, French, German, Spanish, Italian, Dutch and Polish; Twig Hooks; MySQL 8, MariaDB 10.6+ and PostgreSQL support.
Screenshots
Screenshots go in docs/images/.
Requirements
| Version | |
|---|---|
| PHP | 8.2+ |
| Sylius | 2.2, 2.3 |
| Symfony | 6.4, 7.4, 8.0 |
| Database | MySQL 8+, MariaDB 10.6+, PostgreSQL 10+ (see Workers for older servers) |
Installation
-
Require the package:
composer require tmonier/sylius-webhook-outbox-plugin
-
Register the bundle in
config/bundles.php(Symfony Flex does it automatically once a recipe exists):Tmonier\SyliusWebhookOutboxPlugin\TmonierSyliusWebhookOutboxPlugin::class => ['all' => true],
-
Import the configuration in
config/packages/tmonier_sylius_webhook_outbox.yaml:imports: - { resource: "@TmonierSyliusWebhookOutboxPlugin/config/config.yaml" }
-
Import the admin routes in
config/routes/tmonier_sylius_webhook_outbox.yaml:tmonier_sylius_webhook_outbox: resource: "@TmonierSyliusWebhookOutboxPlugin/config/routes.yaml"
-
Run the migrations:
bin/console doctrine:migrations:migrate
-
Run the worker (see Workers) and schedule the purge, for instance daily:
bin/console tmonier:webhook:purge
-
Set
framework.router.default_uri(e.g.https://shop.example.com) so that theadmin_urlof payloads recorded from console commands point to your shop.
Configuration
All keys are optional; these are the defaults.
tmonier_sylius_webhook_outbox: enabled: true # capture events (the worker keeps delivering what is queued) events: # per type switch, e.g. product_variant.stock_changed: { enabled: true } retry_schedule: [0, 5, 300, 1800, 7200, 18000, 36000, 50400, 72000, 86400] # seconds before attempt 1, 2, 3...; its length = attempts retry_jitter: 0.1 # up to +10% on each delay timeout: 15 # idle timeout of a request, seconds max_duration: 30 # total duration of a request, seconds response_body_max_bytes: 2048 # response excerpt kept for the admin panel retention_days: 30 # default of tmonier:webhook:purge (null: keep forever) allow_http_endpoints: false # accept http:// URLs: development only allowed_private_networks: [] # e.g. ['10.0.12.0/24'] for an ERP on your LAN url_redaction_pattern: '…' # path segments hidden in logs / errors / admin (null: none)
An event is stored only when at least one enabled endpoint subscribes to it (and accepts its channel): without endpoints, the plugin writes nothing.
allowed_private_networks punches holes in the blocked ranges, everything else stays
blocked, including after DNS resolution. Use exact ranges.
Events
| Type | When | data |
Channel |
|---|---|---|---|
order.placed |
checkoutState becomes completed (or an order is created completed) |
order | order channel |
order.paid |
paymentState becomes paid |
order | order channel |
order.shipped |
shippingState becomes shipped |
order | order channel |
order.cancelled |
state becomes cancelled |
order | order channel |
customer.created |
a customer is created (guests included: Sylius creates one per checkout e-mail) | customer | null |
customer.updated |
e-mail, first / last name, phone, gender, birthday, group or newsletter changes, or an existing customer (a former guest) creates an account (has_account); changed_fields lists them |
customer | null |
product_variant.stock_changed |
onHand or onHold of variants changes, or a tracked variant is created (initial stock, previous null); one event per flush listing every variant |
stock | null |
webhook.ping |
Ping button; sent to that endpoint only | ping | null |
Several events of one flush are independent: a receiver can get order.paid before
order.placed after a retry. timestamp has a one-second precision and is the same for the
events of one flush: order events by id instead (UUIDv7, time-ordered; the plugin makes the
ids of one process strictly increasing, so order.placed sorts before the order.paid of the
same flush), and rely on the state fields of data.
Envelope
Every request body (schema):
{
"id": "0199d3a8-5b1e-7c43-9a51-2f7f3c1d9e02",
"type": "order.placed",
"version": 1,
"timestamp": "2026-10-11T08:14:03Z",
"channel": "FASHION_WEB",
"data": { "…": "…" }
}
idis a UUIDv7, equal to thewebhook-idheader. It is the same for every retry and replay of the event: use it to deduplicate.versionis the version of thedataDTO of this type. Fields are only added within a version; a breaking change gets a new version.- Dates are ISO 8601 (RFC 3339). Amounts are integers in the minor unit of the currency (cents). Ids are Sylius database ids.
- On MySQL the members of
datamay come in another order than documented (MySQL normalises JSON objects): parse JSON, never compare strings.
order.* data (version 1)
{
"id": 20,
"number": "000000020",
"admin_url": "https://shop.example.com/admin/orders/20",
"channel": "FASHION_WEB",
"currency_code": "USD",
"locale_code": "en_US",
"state": "new",
"checkout_state": "completed",
"payment_state": "awaiting_payment",
"shipping_state": "ready",
"checkout_completed_at": "2026-10-04T00:28:14+00:00",
"totals": { "items": 39105, "shipping": 595, "tax": 0, "promotion": -1000, "total": 39700 },
"promotion_coupon": null,
"customer": { "id": 13, "email": "aniyah@example.com", "first_name": "Aniyah", "last_name": "Larson", "phone_number": "1-520-372-9575", "has_account": true },
"billing_address": { "first_name": "Eva", "last_name": "Batz", "company": null, "street": "8867 Zulauf Estate", "city": "Port Margretshire", "postcode": "62021", "country_code": "PL", "province_code": null, "province_name": null, "phone_number": null },
"shipping_address": { "…": "same members as billing_address" },
"items": [
{ "id": 60, "variant_code": "Cool_Breeze_Jeans-variant-4", "product_code": "Cool_Breeze_Jeans", "product_name": "Cool Breeze Jeans", "variant_name": "XXL", "quantity": 5, "unit_price": 8021, "total": 39105 }
],
"payments": [ { "id": 20, "method_code": "bank_transfer", "state": "new", "amount": 39700, "currency_code": "USD" } ],
"shipments": [ { "id": 20, "method_code": "dhl_express", "state": "ready", "tracking": null, "shipped_at": null } ],
"notes": null
}
Never included: the order token (tokenValue), the customer IP, payment details.
customer.* data (version 1)
{
"id": 13,
"admin_url": "https://shop.example.com/admin/customers/13",
"email": "aniyah@example.com",
"first_name": "Aniyah",
"last_name": "Larson",
"phone_number": "1-520-372-9575",
"gender": "f",
"birthday": "1990-04-02",
"group": "retail",
"subscribed_to_newsletter": false,
"has_account": true,
"created_at": "2026-10-01T09:12:44+00:00",
"changed_fields": ["last_name"]
}
changed_fields is present in customer.updated only. The shop user (password, salt, tokens,
roles) is never exposed: has_account tells whether there is one.
product_variant.stock_changed data (version 1)
{
"variants": [
{ "id": 412, "code": "Cool_Breeze_Jeans-variant-4", "product_code": "Cool_Breeze_Jeans", "admin_url": "https://shop.example.com/admin/products/87/variants/412/edit", "tracked": true, "on_hand": 20, "on_hold": 5, "available": 15, "previous": { "on_hand": 20, "on_hold": 0 } }
]
}
webhook.ping data (version 1)
{ "endpoint_id": 3, "endpoint_name": "ERP", "message": "Ping from Sylius" }
Receiving webhooks
Each request is a POST with Content-Type: application/json and three headers:
webhook-id, webhook-timestamp (Unix seconds) and webhook-signature
(v1,<base64 HMAC-SHA256>, possibly several separated by spaces). The signed content is
{webhook-id}.{webhook-timestamp}.{raw body}, the key is the base64-decoded part of the
whsec_… secret. Answer 2xx quickly (within 15 s) and process asynchronously; any other
answer is retried, 410 Gone disables the endpoint (its pending deliveries are suspended
until an administrator enables it again).
Any Standard Webhooks library works. Without a library:
PHP
function verifyWebhook(string $body, array $headers, string $secret, int $tolerance = 300): array { $id = $headers['webhook-id'] ?? ''; $timestamp = $headers['webhook-timestamp'] ?? ''; if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) { throw new RuntimeException('Invalid timestamp'); } $key = base64_decode(substr($secret, strlen('whsec_')), true); $expected = 'v1,' . base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true)); foreach (explode(' ', $headers['webhook-signature'] ?? '') as $signature) { if (hash_equals($expected, $signature)) { return json_decode($body, true, flags: JSON_THROW_ON_ERROR); } } throw new RuntimeException('Invalid signature'); } // $body must be the raw request body, before any JSON decoding. $event = verifyWebhook(file_get_contents('php://input'), [ 'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? '', 'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', 'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', ], getenv('SYLIUS_WEBHOOK_SECRET'));
Node.js (Express, raw body)
const crypto = require('node:crypto'); const express = require('express'); function verifyWebhook(rawBody, headers, secret, tolerance = 300) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; if (!/^\d+$/.test(timestamp ?? '') || Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) { throw new Error('Invalid timestamp'); } const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = Buffer.from('v1,' + crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64')); const valid = (headers['webhook-signature'] ?? '').split(' ').some((signature) => { const given = Buffer.from(signature); return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); if (!valid) { throw new Error('Invalid signature'); } return JSON.parse(rawBody); } const app = express(); app.post('/webhooks/sylius', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = verifyWebhook(req.body.toString('utf8'), req.headers, process.env.SYLIUS_WEBHOOK_SECRET); } catch (error) { return res.status(400).send(error.message); } queue.push(event); // process asynchronously res.sendStatus(204); });
Idempotency
Delivery is at least once: a timeout after your server processed the request, a worker
crash or an administrator's replay sends the same event again, always with the same
webhook-id. Store the ids you processed (a unique index is enough) and skip duplicates:
CREATE TABLE sylius_webhooks_received (webhook_id CHAR(36) PRIMARY KEY, received_at TIMESTAMP NOT NULL); -- INSERT ... ; on duplicate key: already processed, answer 200 and do nothing.
Keep the ids at least as long as the retry window (the sum of retry_schedule: about 3 days
and 4 hours by default, up to 10% more with the jitter) plus the time an administrator may
replay (the retention, 30 days by default).
Workers
tmonier:webhook:dispatch claims the due deliveries one by one (SELECT … FOR UPDATE SKIP LOCKED, then a lease of max_duration + 60 s), sends them and records the result. Several
workers may run; one is enough for most shops.
| Option | |
|---|---|
| (none) | send what is due, then exit |
--loop |
keep polling (--sleep seconds when idle, default 1) |
--time-limit=N |
stop after N seconds |
--limit=N |
stop after N deliveries |
It finishes the delivery in progress on SIGTERM / SIGINT. A worker killed in the middle of
a request leaves a lease that expires; the delivery is then sent again (receivers deduplicate
with webhook-id).
Cron (every minute). A run only starts a request that max_duration lets finish before
--time-limit (which must therefore be greater than max_duration): with --time-limit=55
and the default 30 s, requests start during the first 25 s of each minute. DNS resolution is
not bounded by max_duration, so flock -n skips a run while the previous one is still busy.
For a steady flow, prefer a long-running --loop worker (systemd, Supervisor below):
* * * * * cd /var/www/shop && flock -n var/webhooks.lock php bin/console tmonier:webhook:dispatch --time-limit=55 >> var/log/webhooks.log 2>&1
systemd (/etc/systemd/system/sylius-webhooks.service):
[Unit] Description=Sylius webhook outbox worker After=network-online.target mysql.service [Service] User=www-data WorkingDirectory=/var/www/shop ExecStart=/usr/bin/php bin/console tmonier:webhook:dispatch --loop --time-limit=3600 --env=prod Restart=always RestartSec=5 [Install] WantedBy=multi-user.target
Supervisor:
[program:sylius-webhooks] command=php /var/www/shop/bin/console tmonier:webhook:dispatch --loop --time-limit=3600 --env=prod user=www-data autorestart=true stopsignal=TERM stopwaitsecs=40
SKIP LOCKED needs DBAL to know the real server version: when doctrine.dbal.server_version
is pinned to an old version (e.g. 5.7, as in the Sylius test application), the worker falls
back to a plain FOR UPDATE. Deliveries are still claimed once, but concurrent workers wait
for each other: run a single worker, or set server_version to your real version
(8.4.0, mariadb-11.4.0, 17).
Commands
| Command | |
|---|---|
tmonier:webhook:dispatch |
the worker, see above |
tmonier:webhook:purge [--days=N] [--dry-run] |
deletes the events older than N days (default retention_days) with their deliveries; events with a delivery still pending or failed are kept |
tmonier:webhook:anonymize-customer <id or e-mail> |
redacts the personal data of the customer's events (payloads) and removes the response excerpts of their deliveries |
Disabled endpoints
When an endpoint is disabled (by an administrator or after 410 Gone), its pending and failed
deliveries become suspended: not attempted, not dead letters, and purgeable after the
retention. Enabling the endpoint again requeues them. Ping, replay and retry are refused while
the endpoint is disabled.
Personal data
Webhook payloads contain personal data (names, e-mails, addresses). Before adding an endpoint, qualify the receiver: your own ERP is not a third party; an iPaaS or a SaaS acting on your behalf is a processor (GDPR art. 28, a data processing agreement is needed); a partner using the data for its own purposes is an independent controller; a receiver outside the EEA is a transfer (chapter V: adequacy decision such as the EU-US Data Privacy Framework, or standard contractual clauses). Subscribe endpoints to the events they need only.
tmonier:webhook:anonymize-customer replaces the personal members (e-mail, names, phone,
address lines, notes, tracking codes...) by null and marks the events as anonymized.
Anonymized events cannot be replayed: resending a blanked payload under the same
webhook-id would either be ignored by the receiver or overwrite its data with blanks.
Retries still pending are sent with the redacted payload.
Secrets
- Generated by the plugin (
whsec_+ 32 random bytes), shown once, in the answer to the creation or regeneration request (Cache-Control: no-store), never stored in the session, never displayed again, never logged. - Stored in clear in
tmonier_webhook_endpoint.secret(they are needed to sign): protect database dumps accordingly. - Regenerating replaces the secret immediately (no overlap period in this version).
- Endpoint URLs are often credentials too (query tokens, Slack / Make / Zapier style paths).
Logs, recorded errors and the admin grids and pages show them rebuilt without user info,
without query string and without the path segments that look like tokens
(
url_redaction_pattern: 6+ characters mixing letters and digits, 5+ digits, or 20+ characters; short letters-only secrets cannot be detected). For Zapier, Slack, Discord and Make hook URLs, the whole path after the hook prefix is hidden.dump(), the profiler and the debug toolbar show the endpoint with its secret and URL redacted (VarDumper caster). Only the endpoint edit form shows the full URL.
Network safety
The deliveries use a dedicated HTTP client: private, loopback and link-local addresses are
refused after DNS resolution (NoPrivateNetworkHttpClient, symfony/http-client 6.4.15+ for
the CVE-2024-50342 fix), redirects are not followed, http:// is refused unless
allow_http_endpoints. Proxy environment variables (HTTP_PROXY, HTTPS_PROXY,
ALL_PROXY) are ignored by this client: a proxy resolves the host itself and would bypass
the check. If your servers can only reach the Internet through a proxy, the webhooks cannot be
sent from them in this version.
- With tmonier/sylius-audit-log-plugin: the endpoint entity is not audited by default. If
you add it to
tmonier_sylius_audit_log.resources, keep the defaultmasked_field_patterns(/secret/i): the trail then records that the secret changed, never its value. Do not add it with custom patterns that drop/secret/i.
Adding event types from another plugin
-
Declare the type with a tagged provider (autoconfigured):
use Tmonier\SyliusWebhookOutboxPlugin\EventType\EventType; use Tmonier\SyliusWebhookOutboxPlugin\EventType\EventTypeProviderInterface; final class QuoteEventTypeProvider implements EventTypeProviderInterface { public function getEventTypes(): iterable { yield new EventType('quote.accepted', version: 1, label: 'app.webhook.quote_accepted'); } }
The type appears in the endpoint form and can be disabled in
events. -
Record the event before flushing the change it describes, so that both are written in the same transaction:
use Tmonier\SyliusWebhookOutboxPlugin\Outbox\OutboxRecorderInterface; use Tmonier\SyliusWebhookOutboxPlugin\Outbox\WebhookEvent; $quote->accept(); $this->recorder->record(new WebhookEvent( 'quote.accepted', ['id' => $quote->getId(), 'number' => $quote->getNumber(), 'total' => $quote->getTotal()], channelCode: $quote->getChannel()->getCode(), resourceType: 'quote', resourceId: $quote->getId(), customerId: $quote->getCustomer()?->getId(), // for the erasure command )); $this->entityManager->flush();
Or derive it from the unit of work like the core events: implement
Capture\ChangeDetectorInterface(autoconfigured, called duringonFlush) and useLazyValue::idOf($entity)for ids of entities inserted in the same flush. An e-invoicing plugin would emitinvoice.issuedthe same way when its invoice gets a number.
Customisation
WebhookSenderInterface: replace the HTTP backend (Svix, symfony/webhook...).- Models (
tmonier_sylius_webhook_outbox.models.*.classes) and the endpoint form type can be overridden like any Sylius resource. - Grids
tmonier_webhook_admin_webhook_endpoint,tmonier_webhook_admin_webhook_delivery,tmonier_webhook_admin_dead_letterand the Twig Hookstmonier_webhook.admin.*can be extended.
How events are captured
During Doctrine's onFlush, detectors read the change sets (checkoutState → completed...),
and the recorder persist()s the event and its deliveries and calls computeChangeSet():
Doctrine inserts them in the transaction it opens right after, together with the order. Ids of
entities inserted in the same flush (a new customer, the order items) are resolved when the
event row is inserted, after them. See SPEC.md for the spike that validated this on
ORM 2.20 and 3.6, and the alternatives that were rejected.
Limitations
- Changes made without the Doctrine ORM (DBAL or SQL queries, bulk DQL
UPDATE) produce no event. - At-least-once delivery, no ordering guarantee between events (no per-endpoint serial queue).
- Webhooks cannot go through an HTTP proxy (see Network safety).
- Capture errors (a bug in a normalizer) are logged as critical and do not block the checkout: the business change wins.
- One secret per endpoint, no overlap when regenerating; no ed25519 (
v1a) signatures. - No rate limiting per endpoint, no automatic disabling after N failures (only on
410 Gone), no alert e-mails, no metrics. - No incoming webhooks, no admin API.
- Sylius Plus RBAC is not integrated: every administrator can manage webhooks.
Development
docker compose up -d mysql mailhog # or your own MySQL composer install (cd vendor/sylius/test-application && yarn install && yarn build) vendor/bin/console doctrine:database:create vendor/bin/console doctrine:migrations:migrate -n vendor/bin/console sylius:fixtures:load -n vendor/bin/console tmonier:webhook:demo # demo endpoints and deliveries (test application only) vendor/bin/phpunit --testsuite unit APP_ENV=test vendor/bin/phpunit --testsuite non-unit # needs a test database with fixtures vendor/bin/phpstan analyse vendor/bin/ecs check
tests/Integration/TransactionalOutboxTest commits for real (no wrapping transaction) and
cleans up after itself; set DATABASE_SERVER_VERSION (e.g. 8.4.0) to test SKIP LOCKED.
License
MIT. See LICENSE.