Scheduled maintenance
The package schedules its own maintenance against the default connection — partition rolling, rotated-secret revocation, the dashboard rollup refresh, endpoint-health sweeps and log pruning. A single-database app wants this on (the default), and it just works.
Turning it off per tenant
A database-per-tenant host must turn it off, or the maintenance runs only on the central database and never on a tenant's — the delivery log grows unbounded and the dashboard reads empty.
Set webhooks.schedule.enabled to false and the package registers nothing in the
scheduler; the commands are unchanged, so run them yourself inside your tenant loop:
// config/webhooks.php
'schedule' => ['enabled' => false],
// then, in your own scheduler, per tenant:
foreach (Tenant::active() as $tenant) {
$tenant->run(fn () => Artisan::call('webhooks:partition-maintenance'));
// …and webhooks:revoke-rotated-secrets, webhooks:refresh-metrics, model:prune, and so on.
}
Which command runs on which cadence is listed in the command reference.
Noticing when one of them stops
Every failure here is quiet, and two of them are quiet in a way that matters. The package cannot wire your alerting for you, but it can say what to connect and where the silence costs something:
| If this stops | What you get | How you find out otherwise |
|---|---|---|
webhooks:revoke-rotated-secrets | a rotated-away signing secret stays valid indefinitely | nothing — the endpoint keeps working |
webhooks:refresh-metrics | the dashboard's counts freeze while the latency figures beside them stay live | the dashboard says so now, past twice the cadence |
webhooks:refresh-endpoint-health | health scores freeze at whatever the last delivery left | nothing |
model:prune | the delivery and call logs grow without bound | disk, eventually |
webhooks:partition-maintenance | deliveries land in the default partition | it heals itself on the next run, and says so |
The first row is the one to wire first. It is the only one whose failure widens a security window rather than degrading a view.
Failure notifications
// app/Console/Kernel.php (or bootstrap/app.php on Laravel 11+)
Schedule::command('webhooks:revoke-rotated-secrets')
->onFailure(fn () => Log::critical('Rotated webhook secrets were not revoked.'));
onFailure() fires on a non-zero exit. Both sweeping commands exit non-zero when they could not
process an endpoint — they finish the rest of the run first, so this is a report about specific
rows rather than an aborted job. emailOutputOnFailure() carries the message with it, which is
useful because that message names the endpoint ids.
A dead-man switch, for the ones that fail silently
A failure notification only fires when the command runs and fails. It says nothing when the scheduler itself stops, the container is not running, or an overlap mutex is stuck. For that you need the opposite signal — a ping on success that something outside notices the absence of:
Schedule::command('webhooks:revoke-rotated-secrets')
->pingOnSuccess('https://your-heartbeat-service/…');
Point it at whatever you already use. The value is not the service; it is that absence becomes an alert, which is the only shape that catches a scheduler that never ran.
More than one application server
The Laravel scheduler runs on every server it is installed on. Nothing about that is specific
to this package, and it costs more here than usual: webhooks:partition-maintenance issues DDL,
and two copies can collide on it.
Every command this package schedules is registered with onOneServer(). Each of them writes
shared state — partitions, pruned rows, cached health scores, the dashboard rollup — so a second
concurrent run is a second writer rather than redundancy. You do not need to add anything.
What you do need is a cache store that is shared between your servers, and that is a
different question from whether the store can take a lock. Every first-party Laravel store can:
file and array implement the lock contract just like redis does. But a file lock lives on
one server's disk and an array lock lives in one process, so on a cluster each server takes its
own and every one of them decides it is the chosen server.
Nothing throws and nothing is logged when that happens — onOneServer() simply stops meaning
anything. So on more than one application server, point the cache at redis, memcached,
database or dynamodb. On a single server it does not matter which you use.
If you re-register any of these commands yourself, carry onOneServer() over with them.
Overlap expiry
Every guard the package registers names its own expiry rather than taking Laravel's day-long
default. A hard kill — SIGKILL, an OOM kill, a container stop — does not release the mutex, and
with the default a five-minute job is then skipped for up to 24 hours. withoutOverlapping is
implemented as a skip, so nothing turns red while that happens.
If you re-register any of these commands yourself, pass an expiry of your own: past a real run of that command, under the gap to the next one.