Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Cadence Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install the package plus exactly one driver. Cadence ships no recurrence engine of its own — drivers self-register only when their backing library is present.

composer require directorytree/cadence

# Cron — already a transitive dependency of Laravel core, so effectively "free"
composer require dragonmantank/cron-expression

# or RRULE via php-rrule
composer require rlanvin/php-rrule

# or RRULE via Recurr
composer require simshaun/recurr

2. Publish and migrate.

php artisan vendor:publish --provider="DirectoryTree\Cadence\CadenceServiceProvider"
php artisan migrate

You get a schedules table: schedulable_type/schedulable_id, type, expression, timezone, next_run_at, last_run_at, disabled_at. Note that next_run_at is precomputed and persisted — that single column is the entire "is this due?" decision.

3. Wire the loop. One model, one schedule, one listener, one cron entry:

class Report extends Model implements Schedulable
{
    use HasSchedules;
}

Report::find(1)->addSchedule(new CronSchedule('0 12 * * *'));
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('schedules:run')->withoutOverlapping()->everyMinute();
class GenerateReport implements ShouldQueue
{
    public function shouldQueue(ScheduleTriggered $event): bool
    {
        return $event->schedule->schedulable_type === Report::class; // README shows `=` here; use `===`
    }

    public function handle(ScheduleTriggered $event): void
    {
        $event->schedulable->generate();
    }
}

Where to look first when it doesn't fire, in order:

  1. select * from schedules — if next_run_at is NULL the schedule is disabled or the expression failed to resolve. Nothing else matters until that's non-null and in the past.
  2. DirectoryTree\Cadence\Events\ScheduleTriggered — carries $schedule and $schedulable, uses SerializesModels since v1.0.1.
  3. src/Drivers/ — CronSchedule, RruleSchedule, RecurrSchedule, and the base Schedule::resolveNextOccurrence().
  4. The schedules:run command — the only thing that advances next_run_at.

Best first use case: per-record cadence that genuinely varies row to row — digests, subscription renewals, workspace cleanup, report regeneration. If your first instinct is one system-wide cron line, stop and use Laravel's Schedule facade; you don't need this dependency.


Implementation Patterns

Recurrence as data, driven from a UI

The whole justification for Cadence is that "when does this run" becomes a row instead of a routes/console.php entry. Expose it:

// Admin picks "weekdays at 9am" and the tenant's own timezone
$workspace->addSchedule(
    new CronSchedule('0 9 * * 1-5', $workspace->timezone)
);

Because timezone is a column and next_run_at is precomputed, global tenants fire at their local 9am without any per-tenant cron lines. This is the pattern that earns the extra dependency.

Multiple schedules per model

addSchedule is additive — a model can carry several, each with its own driver and timezone:

$invoice->addSchedule(new CronSchedule('0 9 * * *',  'America/New_York'));   // reminder
$invoice->addSchedule(new CronSchedule('0 9 * * 1',  'America/New_York'));   // weekly summary
$invoice->addSchedule(new RruleSchedule('DTSTART=20260101T000000;FREQ=MONTHLY;BYMONTHDAY=1'));

Route each to a distinct listener via shouldQueue() predicates rather than one giant switch.

Enable/disable is your "pause" product control

v1.1.0's disabled_at is a soft-off, not a delete:

$schedule->disable();   // sets disabled_at, clears next_run_at
$schedule->enable();    // recomputes next_run_at from now

$schedule->isEnabled();
$schedule->isDisabled();
Schedule::enabled()->get();
Schedule::disabled()->get();

Re-enabling anchors from current time, so a paused-then-resumed daily schedule does not dump a backlog. That's normally what you want — say so in your UI copy.

Small, filtered listeners

class SendDigest implements ShouldQueue
{
    public function shouldQueue(ScheduleTriggered $event): bool
    {
        return $event->schedulable instanceof Workspace;
    }

    public function handle(ScheduleTriggered $event): void
    {
        $event->schedulable->sendDigest();
    }
}

Because the event serializes models, a queued listener re-fetches $schedulable at handle time — which is why you must guard against records deleted between dispatch and execution.

Custom driver for domain recurrence

When "every 3rd business day after invoice creation" doesn't fit cron or RRULE, implement the base driver and register it:

namespace App\Drivers;

use Carbon\CarbonInterface;
use DirectoryTree\Cadence\Drivers\Schedule;

class BusinessDaySchedule extends Schedule
{
    protected function resolveNextOccurrence(CarbonInterface $after): ?CarbonInterface
    {
        // return null to indicate "no further occurrences"
    }
}
// AppServiceProvider::boot()
use DirectoryTree\Cadence\Schedule;

Schedule::driver('business-day', BusinessDaySchedule::class);

Returning null is meaningful — it's how you express terminal schedules (which will leave next_run_at null and stop the row from being picked up).

Configuring the underlying library

Every shipped driver exposes tap() for one-off library configuration:

CronSchedule::tap(fn (CronExpression $cron) => /* ... */);
RecurrSchedule::tap(fn (Rule $rule, ArrayTransformer $t) => $t->setConfig(
    (new ArrayTransformerConfig)->enableLastDayOfMonthFix()
));

tap(null) clears it. Use this instead of forking a driver for small quirks.

Testing

Freeze time and assert on persisted state, not on dispatch side effects alone:

Carbon::setTestNow('2026-03-02 08:59:00');

$report->addSchedule(new CronSchedule('0 9 * * *', 'America/New_York'));

$this->artisan('schedules:run')->assertSuccessful();

Event::assertDispatched(ScheduleTriggered::class);
$this->assertSame('2026-03-03 09:00:00', $report->schedules()->first()->next_run_at->toDateTimeString());

Run the command twice and assert the second run is a no-op — that's your regression test for next_run_at advancement.


Gotchas and Tips

No driver installed = silent, confusing failure. Drivers register off library presence. composer require directorytree/cadence alone gets you a table and a Schedule::driver() lookup that doesn't resolve cron. Install a driver in the same PR and assert it in CI.

next_run_at IS NULL is the universal symptom. It means disabled, or resolveNextOccurrence() returned null, or the expression is unparseable. Query it first before reading any application code.

The README listener example has a typo — $event->schedule->schedulable_type = Report::class is an assignment, not a comparison. Use ===. Also remember schedulable_type is the morph alias, so a custom morph map means it won't equal Report::class.

addSchedule() appends, it doesn't upsert. Calling it on every page save silently accumulates duplicate rows, each dispatching its own event. Dedupe yourself (firstOrCreate-style on type + expression + timezone) or gate creation behind an explicit "save schedule" action. There's no unique index to save you.

Polymorphic rows outlive their models. There's no FK cascade on schedulable_type/schedulable_id. Deleting a Report leaves orphan schedules that still dispatch events whose $schedulable is null after deserialization. Clean up in a deleting observer, and null-check in listeners.

schedules:run must be registered or nothing happens. It's a plain artisan command, not a self-scheduling service provider. ->everyMinute() plus ->withoutOverlapping() is the minimum. On multi-server deployments withoutOverlapping() is per-host — add ->onOneServer() (requires a shared cache lock) or you'll advance next_run_at and dispatch events N times.

Missing minutes are dropped, not backfilled. The command advances from "now," so a five-minute outage produces one firing, not five. If your business logic needs gap-filling, compute it in the listener from last_run_at — don't assume the scheduler owes you those runs.

last_run_at is not a reliable audit trail for overlapping work. The command dispatches an event and moves on; it does not wait for queued listeners. Two schedule rows firing at the same tick race in the queue, and a failed listener does not roll back next_run_at. Make handlers idempotent.

Timezone mutation bug was fixed in v1.1.0 — pin ^1.1 at minimum if you use per-schedule timezones, since earlier versions could mutate the CarbonInterface passed into occurrence resolution and corrupt subsequent calculations.

Upgrading: the disabled_at migration is separate. v1.1.0's upgrade notes require re-publishing and migrating before touching enable()/disable(). On an existing install, confirm the column exists in staging before deploying code that calls isDisabled().

Cron is 5-field, no seconds. dragonmantank/cron-expression will reject 6-field Quartz-style expressions. For "last business day of the month," "every 2nd Tuesday," or anything with COUNT/UNTIL, reach for RRULE instead — cron genuinely cannot express it.

Verify the index on next_run_at. The command scans next_run_at <= now() every sixty seconds forever. Check the published migration has that index; add one in your own migration if not, and monitor the table once you're past a few hundred thousand rows.

58 stars, no dependents, pre-2.0. The API surface is small and readable — budget an afternoon to actually read src/ before leaning on it. Pin to ^1.1, and treat the driver base class as the extension point you may need to patch locally rather than the library itself.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator