Skip to content
DirectoryTreePublic

About

Model-based scheduling for Laravel

Resources

Stars

58 stars

Watchers

1 watching

Forks

Repository files navigation

Cadence

Model-based scheduling for Laravel.

Tests Total Downloads Latest Version License

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.

Contents

Requirements

  • PHP >= 8.2
  • Laravel >= 11.0

Installation

You can install the package via composer:

composer require directorytree/cadence

Then, 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/recurr

Publish and run the migrations:

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

This creates a schedules table with the following columns:

  • schedulable_type / schedulable_id — polymorphic relation to your model
  • type — the driver type (e.g. cron, rrule, recurr)
  • expression — the schedule expression
  • timezone — optional timezone for the schedule
  • next_run_at — precomputed next occurrence for efficient querying
  • last_run_at — timestamp of the last run
  • disabled_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 migrate

Setup

Implement 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;
}

Usage

Adding Schedules

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'));

Timezones

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.

Disabling Schedules

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();

Running Due Schedules

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.

Listening for Triggered Schedules

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.

Handling Late Runs

Each ScheduleTriggered event includes two immutable timestamps:

  • scheduledAt — when the occurrence was due
  • dispatchedAt — 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.

Drivers

Cadence uses a driver-based architecture. Drivers are automatically registered when their backing library is installed.

Cron

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 9am

RRULE (php-rrule)

Requires 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');

RRULE (Recurr)

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.

Custom Drivers

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);

Customizing Drivers

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);

About

Model-based scheduling for Laravel

Resources

Stars

58 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages