Model-based scheduling for Laravel.
Contents · Installation · Usage
Cadence provides a driver-based scheduling system for your Eloquent models using cron expressions or RRULE recurrence patterns. Attach one or many schedules to any model, and Cadence will track and dispatch events when they're due.
- PHP >= 8.2
- Laravel >= 11.0
You can install the package via composer:
composer require directorytree/cadenceThen, install at least one schedule driver:
# Cron (recommended for simple schedules)
composer require dragonmantank/cron-expression
# RRULE via php-rrule
composer require rlanvin/php-rrule
# RRULE via Recurr
composer require simshaun/recurrPublish and run the migrations:
php artisan vendor:publish --provider="DirectoryTree\Cadence\CadenceServiceProvider"
php artisan migrateThis creates a schedules table with the following columns:
schedulable_type/schedulable_id— polymorphic relation to your modeltype— the driver type (e.g.cron,rrule,recurr)expression— the schedule expressiontimezone— optional timezone for the schedulenext_run_at— precomputed next occurrence for efficient queryinglast_run_at— timestamp of the last rundisabled_at— timestamp indicating the schedule is disabled
If you're upgrading from an existing Cadence installation, publish and run the new migration before using the schedule enable / disable APIs:
php artisan vendor:publish --provider="DirectoryTree\Cadence\CadenceServiceProvider"
php artisan migrateImplement the Schedulable interface and use the HasSchedules trait on any model you want to schedule:
// app/Models/Report.php
namespace App\Models;
use DirectoryTree\Cadence\HasSchedules;
use DirectoryTree\Cadence\Schedulable;
use Illuminate\Database\Eloquent\Model;
class Report extends Model implements Schedulable
{
use HasSchedules;
}Create a driver instance and add it to your model:
use DirectoryTree\Cadence\Drivers\CronSchedule;
$report = Report::find(1);
// Every day at noon
$report->addSchedule(new CronSchedule('0 12 * * *'));
// Every Monday at 9am
$report->addSchedule(new CronSchedule('0 9 * * 1'));With RRULE expressions:
use DirectoryTree\Cadence\Drivers\RruleSchedule;
// Every weekday
$report->addSchedule(new RruleSchedule('FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR'));
// Monthly on the 15th, starting from a specific date
$report->addSchedule(new RruleSchedule('DTSTART=20260101T000000;FREQ=MONTHLY;BYMONTHDAY=15'));Schedules can be timezone-aware. Pass the timezone as the second argument:
// Every day at 9am Eastern
$report->addSchedule(
new CronSchedule('0 9 * * *', 'America/New_York')
);Or set it via the setTimezone() method:
$schedule = new CronSchedule('0 9 * * *');
$schedule->setTimezone('America/New_York');
$report->addSchedule($schedule);The schedule's timezone only determines when occurrences happen. Cadence converts computed run dates to your application's timezone before storing them, so the due() scope and schedules:run command fire at the correct moment.
Saving a timezone change on a schedule recalculates next_run_at from the current time. It preserves last_run_at, and disabled schedules keep next_run_at set to null.
$schedule->update(['timezone' => 'America/Los_Angeles']);For RRULE schedules with a local DTSTART, the start keeps the same local date and time in the new timezone. Changing the timezone does not reset the start or the rule's COUNT or INTERVAL.
Schedules may be disabled without deleting them:
$schedule->disable();Disabling a schedule sets disabled_at and clears next_run_at, so the schedule will not be picked up by the schedules:run command.
Enable the schedule again to compute its next occurrence from the current time:
$schedule->enable();You may also check or query schedule state:
use DirectoryTree\Cadence\Schedule;
$schedule->isEnabled();
$schedule->isDisabled();
Schedule::enabled()->get();
Schedule::disabled()->get();Register the schedules:run command in your application's scheduler to run every minute:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('schedules:run')
->withoutOverlapping()
->everyMinute();This command queries all schedules where next_run_at <= now(), dispatches a ScheduleTriggered event for each, and advances next_run_at to the next occurrence.
If dispatching or advancing a schedule throws an exception, the command reports it through Laravel's exception handler and continues processing the remaining schedules. Like Laravel's scheduler, it returns a successful exit code when processing finishes, even if individual schedules failed. A schedule whose event fails to dispatch is not advanced, so it can be attempted again on the next run.
Listeners may receive the same occurrence again if part of the dispatch succeeds before a failure, or if advancing the schedule fails after dispatch.
To handle duplicate deliveries, combine $event->schedule->getKey() and $event->scheduledAt->getTimestamp() into a stable key for the occurrence, scoped to each listener. For database work, enforce uniqueness on that key and record it in the same transaction as the work. For external services that support idempotency keys, pass the same key on each attempt so repeated requests do not repeat the side effect.
Listen for the ScheduleTriggered event to perform work when a schedule fires:
namespace App\Listeners;
use App\Models\Report;
use Illuminate\Contracts\Queue\ShouldQueue;
use DirectoryTree\Cadence\Events\ScheduleTriggered;
class GenerateReport implements ShouldQueue
{
public function shouldQueue(ScheduleTriggered $event): bool
{
return $event->schedule->schedulable_type === Report::class;
}
public function handle(ScheduleTriggered $event): void
{
$event->schedule->schedulable->generate();
}
}Register it in your EventServiceProvider or use event discovery.
Each ScheduleTriggered event includes two immutable timestamps:
scheduledAt— when the occurrence was duedispatchedAt— when Cadence dispatched the occurrence
These timestamps remain unchanged when the schedule advances, including when a queued listener reloads the schedule from the database. Use them to decide whether a late occurrence is still useful. For example, a queued listener can decline work dispatched more than five minutes late:
public function shouldQueue(ScheduleTriggered $event): bool
{
return $event->dispatchedAt->lessThanOrEqualTo(
$event->scheduledAt->addMinutes(5)
);
}Cadence still updates last_run_at and advances next_run_at when a listener declines the work. These fields track Cadence triggering the occurrence, not whether a listener completes its work. The timestamps describe dispatch timing; any delay waiting in a queue is separate.
Cadence uses a driver-based architecture. Drivers are automatically registered when their backing library is installed.
Requires dragonmantank/cron-expression:
This package is already required by the Laravel framework core.
use DirectoryTree\Cadence\Drivers\CronSchedule;
new CronSchedule('0 12 * * *'); // Every day at noon
new CronSchedule('*/15 * * * *'); // Every 15 minutes
new CronSchedule('0 9 * * 1-5'); // Weekdays at 9amRequires rlanvin/php-rrule:
use DirectoryTree\Cadence\Drivers\RruleSchedule;
new RruleSchedule('FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR');
new RruleSchedule('FREQ=MONTHLY;BYMONTHDAY=1;COUNT=12');Requires simshaun/recurr:
use DirectoryTree\Cadence\Drivers\RecurrSchedule;
new RecurrSchedule('FREQ=WEEKLY;BYDAY=MO,WE,FR');
new RecurrSchedule('FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1');Both RRULE drivers add a DTSTART when the expression is first evaluated or serialized. The start uses the schedule's timezone, or the application's default timezone when none is provided. addSchedule() stores the start and timezone so COUNT and INTERVAL stay anchored when the schedule is reloaded.
To choose a specific start, include DTSTART in the expression. Explicit starts are preserved. Existing stored expressions without DTSTART should be updated with the intended start; their original implicit start was not recorded.
Create a class that extends the base Schedule driver:
namespace App\Drivers;
use Carbon\CarbonInterface;
use DirectoryTree\Cadence\Drivers\Schedule;
class CustomSchedule extends Schedule
{
protected function resolveNextOccurrence(CarbonInterface $after): ?CarbonInterface
{
// Your recurrence logic here
}
}Then register it in your AppServiceProvider:
use App\Drivers\CustomSchedule;
use DirectoryTree\Cadence\Cadence;
Cadence::register('custom', CustomSchedule::class);Each driver exposes a static tap method to configure the underlying library instance before it's used:
Callbacks are scoped to the concrete driver class. Configuring or clearing one driver's callback does not affect other drivers or subclasses.
use Cron\CronExpression;
use DirectoryTree\Cadence\Drivers\CronSchedule;
CronSchedule::tap(function (CronExpression $cron) {
// Configure the CronExpression instance
});use Recurr\Rule;
use Recurr\Transformer\ArrayTransformer;
use Recurr\Transformer\ArrayTransformerConfig;
use DirectoryTree\Cadence\Drivers\RecurrSchedule;
RecurrSchedule::tap(function (Rule $rule, ArrayTransformer $transformer) {
$transformer->setConfig(
(new ArrayTransformerConfig)->enableLastDayOfMonthFix()
);
});Pass null to clear the callback:
RecurrSchedule::tap(null);