directorytree/cadence
Cadence adds driver-based scheduling to Laravel Eloquent models. Attach one or many cron or RRULE recurrence schedules to any model, and Cadence tracks and dispatches events automatically when they're due.
Architecture fit
Cadence is a thin, well-scoped persistence layer for recurrence, not a scheduler. It does not replace Laravel's Illuminate\Console\Scheduling\Schedule; it sits alongside it, converting "when should this fire" from code into rows. That distinction is the whole architectural argument.
schedules table (schedulable_type/schedulable_id) plus a precomputed, indexed next_run_at is the correct data model for this, and it's the design most teams end up hand-rolling badly.routes/console.php and skip the dependency entirely. The package earns its place only when recurrence is user-configurable or per-record.Schedule::resolveNextOccurrence() and static tap() hooks for configuring the underlying cron/RRULE library is a mature pattern. The v1.1.0 enable/disable support (disabled_at, clearing next_run_at) maps cleanly onto a real product control — "pause my weekly digest" — without delete semantics.Integration feasibility
Low-to-moderate. The happy path is genuinely short: composer require, publish + migrate, implement Schedulable + HasSchedules on a model, addSchedule(), register schedules:run every minute, listen for ScheduleTriggered. A competent Laravel engineer gets one schedule firing in under a day.
The friction is in the edges, not the core:
addSchedule() fails with no configuration step to point at. This is a discoverability trap that will generate support tickets.$event->schedule->schedulable_type = Report::class — assignment, not comparison, in a shouldQueue predicate). Small, but it signals a documentation surface that is one README and a changelog, with no docs site, no API reference, and no upgrade guide beyond per-release notes. Budget for reading src/.schedules schema is now yours. Future upstream schema changes require manual re-publish-and-diff, as called out in the v1.1.0 upgrade notes.Technical risk
next_run_at is the single source of truth and only schedules:run advances it. If the scheduler entry is missing, misconfigured, or withoutOverlapping is holding a stale lock, schedules silently stop firing with no error. This is the highest-severity risk and it is a monitoring problem, not a code problem.ScheduleTriggered per row, and advances next_run_at. What happens when a listener throws mid-run is not documented — whether next_run_at was already advanced (lost run) or not (repeated run next minute). Treat delivery as at-least-once and make listeners idempotent.withoutOverlapping() is local to the host unless you add onOneServer() with a shared cache. Two app servers and a naive scheduler config produces duplicate dispatches.Key questions to resolve before committing
schedules rows and due-per-minute rate? (Determines whether a single schedules:run is adequate.)onOneServer()?Stack fit
Cadence assumes a conventional Laravel monolith or modular monolith with an Eloquent domain layer, a working queue, and a single scheduled-entry pattern. It fits a Laravel 11/12 codebase with queue workers already in place. It does not fit Laravel 10, nor a multi-tenant setup where schedules must be isolated per database — the schedules table lives in the app's default connection and tenancy is not addressed.
Migration path Purely additive and low-blast-radius:
schedules table, no changes to existing models or schema.routes/console.php entries; nothing forces a migration.php artisan migrate:rollback plus removing the scheduler entry — the only residual state is orphaned listener code.Compatibility
composer.json first.dragonmantank/cron-expression is already in Laravel core). Add RRULE only when a concrete product requirement demands "every second Tuesday" or "last business day of the month" semantics that cron expresses badly. Each added driver is another library in the supply chain for marginal feature gain.tap() hooks make a custom driver viable if we need bespoke recurrence (e.g., business-calendar-aware), at the cost of owning that logic.Sequencing
next_run_at in the past, run schedules:run, confirm event dispatch, model rehydration via SerializesModels, and next_run_at advancement. Separately test disable()/enable() semantics — specifically confirm re-enable computes from now, not the old anchor, so a paused daily schedule doesn't fire a backlog.RecurrenceService / adapter that owns addSchedule, disable, enable, and expression validation. Do not let CronSchedule leak into controllers, Livewire components, or Filament resources. This is the single highest-leverage decision: it keeps the vendor swappable and gives you one place to add validation, audit, and telemetry.next_run_at. Copy must state that re-enabling starts from now.->withoutOverlapping()->onOneServer()->everyMinute(); alerting on overdue next_run_at and on schedules stuck at NULL; queue-based listeners with idempotency keys.shouldQueue() predicates, not one large switch.Maintenance Small, stable surface: one table, one command, one event, four driver classes. Pinning the version and treating upgrades as routine is sufficient. The real maintenance line item is the published migration — the schema is now ours to evolve, and upstream changes require manual reconciliation. Watch Laravel major releases: a package at 58 stars with four lifetime releases will not always be patched promptly, so budget a "can we patch it ourselves" reserve. MIT licensing and a compact codebase make that reserve credible rather than theoretical.
Support
Community-only. No vendor SLA, no docs site, no issue-triage guarantees. All debugging knowledge lives with the adopting team. Mitigate by writing an internal runbook at adoption time covering: where next_run_at lives, how to manually force a schedule, how to verify the scheduler entry, and how to inspect the event payload. Without that runbook, the first incident will be expensive regardless of how simple the package is.
Scaling
The read path is fine: an indexed next_run_at <= now() query per minute handles tens of thousands of rows without drama. The scaling problem is fan-out, not query cost:
schedules:run by schedulable_type once the single command's runtime approaches the minute boundary — that's the natural break point, and it's a cheap change.onOneServer() plus a shared cache; without it, duplicate execution scales linearly with node count.Failure modes (ordered by likelihood × severity)
next_run_at in the past and growing. Highest severity because it is invisible. Mitigation: alert on MIN(next_run_at) < now() - 15 minutes.onOneServer(), or a listener that partially succeeded before throwing. Mitigation: idempotency keys in handlers; treat as at-least-once.next_run_at is advanced before the listener completes, the occurrence is gone. Mitigation: idempotent handlers plus a dead-letter path; do not rely on the package for retry.Schedulable contract. Symptom: confusing exception at addSchedule() or a SQL error at runtime. Mitigation: validate at boot / in the internal adapter, and cover in the runbook.>=1.1.0 and never downgrade. Verify DST transitions explicitly in the spike; they are the classic failure for "9am local" schedules.Ramp-up Engineering: roughly half a day to first firing, one to two days to production-grade wiring with the adapter, monitoring, and idempotent listeners. The learning curve is not the code — it's the product semantics the team must decide and document: catch-up policy, pause/resume behavior, timezone precedence (schedule vs. tenant vs. app default), and whether schedules survive deletion of their parent model. Those decisions outlast the package and should be settled before the first line of integration code.
How can I help you explore Laravel packages today?