Skip to main content

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 stopsWhat you getHow you find out otherwise
webhooks:revoke-rotated-secretsa rotated-away signing secret stays valid indefinitelynothing — the endpoint keeps working
webhooks:refresh-metricsthe dashboard's counts freeze while the latency figures beside them stay livethe dashboard says so now, past twice the cadence
webhooks:refresh-endpoint-healthhealth scores freeze at whatever the last delivery leftnothing
model:prunethe delivery and call logs grow without bounddisk, eventually
webhooks:partition-maintenancedeliveries land in the default partitionit 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.