v0.0.1 Latest

Implementation Guide

This guide walks through practical usage of crumbls/timeline from installation through building a live event calendar feed.


Installation

composer require crumbls/timeline

Publish and run the migrations:

php artisan vendor:publish --tag=timeline-migrations
php artisan migrate

Optionally publish the config:

php artisan vendor:publish --tag=timeline-config

Configuration

config/timeline.php:

return [
    'table_prefix'                 => 'timeline_',
    'occurrence_generation_months' => 12,
    'default_timezone'             => 'UTC',
];

table_prefix controls every table name in the package. Change it before running migrations if your database has naming conventions.

occurrence_generation_months controls how far ahead the generator pre-computes occurrences when a Rule is saved.


Core Concepts

Concept What it is
Event The conceptual thing — "Laravel Meetup", "Board Meeting"
Rule When it happens — an RRULE string or a single date
Occurrence A concrete scheduled instance — "Meetup on June 5th"
OccurrenceException A one-off change to a single Occurrence
Location A reusable venue attached to Occurrences

Query Occurrences, not Events. Events are containers. Everything a user sees comes from the Occurrence model.


Creating Events

One-time event

use Crumbls\Timeline\Models\Event;
use Crumbls\Timeline\Models\Rule;
use Crumbls\Timeline\Enums\EventStatus;

$event = Event::create([
    'name'        => 'Product Launch',
    'description' => 'Q3 product announcement',
    'timezone'    => 'America/Denver',
    'status'      => EventStatus::Published,
]);

Rule::create([
    'event_id'  => $event->id,
    'starts_at' => '2025-09-15 14:00:00',
    'ends_at'   => '2025-09-15 16:00:00',
    // No rrule — treated as a single occurrence
]);

Saving the Rule automatically dispatches GenerateOccurrencesJob, which creates the single Occurrence record.


Weekly recurring event

$event = Event::create([
    'name'     => 'Laravel Meetup',
    'timezone' => 'America/Denver',
    'status'   => EventStatus::Published,
]);

Rule::create([
    'event_id'  => $event->id,
    'starts_at' => '2025-06-03 18:00:00',  // First Tuesday
    'ends_at'   => '2025-06-03 20:00:00',  // 2-hour duration
    'rrule'     => 'FREQ=WEEKLY;BYDAY=TU',
]);

Event with multiple rules

An Event can have any number of Rules. This is the correct way to model "first and third Thursday of the month":

$event = Event::create([
    'name'   => 'Board Meeting',
    'status' => EventStatus::Published,
]);

// First Thursday
Rule::create([
    'event_id'  => $event->id,
    'starts_at' => '2025-06-05 09:00:00',
    'ends_at'   => '2025-06-05 10:00:00',
    'rrule'     => 'FREQ=MONTHLY;BYDAY=1TH',
]);

// Third Thursday
Rule::create([
    'event_id'  => $event->id,
    'starts_at' => '2025-06-19 09:00:00',
    'ends_at'   => '2025-06-19 10:00:00',
    'rrule'     => 'FREQ=MONTHLY;BYDAY=3TH',
]);

Rule with an end date

Use until_at to stop generating occurrences after a specific date. The RRULE UNTIL clause is also respected, but until_at lets you control it at the model level without modifying the RRULE string.

Rule::create([
    'event_id'  => $event->id,
    'starts_at' => '2025-06-03 18:00:00',
    'rrule'     => 'FREQ=WEEKLY;BYDAY=TU',
    'until_at'  => '2025-12-31 23:59:59',
]);

Locations

use Crumbls\Timeline\Models\Location;
use Crumbls\Timeline\Models\Occurrence;

$venue = Location::create([
    'name'       => 'Denver Tech Center',
    'address_1'  => '4700 S. Syracuse St.',
    'city'       => 'Denver',
    'state'      => 'CO',
    'postal_code' => '80237',
    'country'    => 'US',
    'timezone'   => 'America/Denver',
    'latitude'   => 39.6475,
    'longitude'  => -104.8997,
]);

// Attach a location when the Occurrence is generated, or update it after:
Occurrence::where('event_id', $event->id)
    ->scheduled()
    ->update(['location_id' => $venue->id]);

To attach a location to all future occurrences of a specific event from the Rule level, set location_id on each Occurrence after generation — or override it per-occurrence using an OccurrenceException.


Querying Occurrences

All queries go through the Occurrence model.

Upcoming occurrences

use Crumbls\Timeline\Models\Occurrence;

$occurrences = Occurrence::upcoming()
    ->with(['event', 'location'])
    ->orderBy('starts_at')
    ->get();

Today

$today = Occurrence::today()
    ->scheduled()
    ->with('event')
    ->get();

Between two dates

use Carbon\Carbon;

$occurrences = Occurrence::between(
    Carbon::parse('2025-06-01'),
    Carbon::parse('2025-06-30')
)->scheduled()->orderBy('starts_at')->get();

For a specific event

$occurrences = Occurrence::forEvent($event->id)
    ->upcoming()
    ->get();

// Or via the relationship:
$occurrences = $event->occurrences()
    ->upcoming()
    ->get();

At a specific location

$occurrences = Occurrence::atLocation($venue->id)
    ->between(Carbon::now(), Carbon::now()->addMonth())
    ->get();

Chaining scopes

Scopes compose freely:

$occurrences = Occurrence::scheduled()
    ->upcoming()
    ->forEvent($event->id)
    ->with(['event', 'location', 'exceptions'])
    ->orderBy('starts_at')
    ->paginate(20);

Building a Calendar Feed

A calendar feed is a date-ranged list of occurrences with their event and location data. The pattern is always the same: query by range, eager-load relationships, transform.

Basic controller

use Crumbls\Timeline\Models\Occurrence;
use Carbon\Carbon;
use Illuminate\Http\Request;

class CalendarFeedController extends Controller
{
    public function __invoke(Request $request)
    {
        $start = Carbon::parse($request->get('start', Carbon::now()->startOfMonth()));
        $end   = Carbon::parse($request->get('end', Carbon::now()->endOfMonth()));

        $occurrences = Occurrence::between($start, $end)
            ->scheduled()
            ->with(['event', 'location', 'exceptions'])
            ->orderBy('starts_at')
            ->get();

        return response()->json(
            $occurrences->map(fn ($o) => $this->transform($o))
        );
    }

    private function transform(Occurrence $occurrence): array
    {
        return [
            'id'         => $occurrence->uuid,
            'title'      => $occurrence->event->name,
            'start'      => $occurrence->starts_at->toIso8601String(),
            'end'        => $occurrence->ends_at?->toIso8601String(),
            'status'     => $occurrence->status->value,
            'location'   => $occurrence->location ? [
                'name'    => $occurrence->location->name,
                'address' => $occurrence->location->address_1,
                'city'    => $occurrence->location->city,
                'lat'     => $occurrence->location->latitude,
                'lng'     => $occurrence->location->longitude,
            ] : null,
        ];
    }
}

Route

Route::get('/calendar/feed', CalendarFeedController::class)->name('calendar.feed');

Fetching

GET /calendar/feed?start=2025-06-01&end=2025-06-30

Handling Exceptions (One-off Changes)

Use OccurrenceException to modify a single occurrence without touching the Rule.

Cancel one occurrence

use Crumbls\Timeline\Models\OccurrenceException;
use Crumbls\Timeline\Enums\ExceptionAction;

$occurrence = Occurrence::find($id);

OccurrenceException::create([
    'occurrence_id' => $occurrence->id,
    'action'        => ExceptionAction::Cancel,
]);

$occurrence->update(['status' => \Crumbls\Timeline\Enums\OccurrenceStatus::Cancelled]);

Reschedule one occurrence

OccurrenceException::create([
    'occurrence_id' => $occurrence->id,
    'action'        => ExceptionAction::Reschedule,
    'starts_at'     => '2025-06-12 19:00:00',
    'ends_at'       => '2025-06-12 21:00:00',
]);

$occurrence->update([
    'starts_at' => '2025-06-12 19:00:00',
    'ends_at'   => '2025-06-12 21:00:00',
]);

Change location for one occurrence

OccurrenceException::create([
    'occurrence_id' => $occurrence->id,
    'action'        => ExceptionAction::Modify,
    'location_id'   => $newVenue->id,
    'payload'       => ['reason' => 'Venue change for this week only'],
]);

$occurrence->update(['location_id' => $newVenue->id]);

The Exception record serves as an audit trail. The Occurrence itself holds the current state.


Regenerating Occurrences

When a Rule is created or updated, GenerateOccurrencesJob is dispatched automatically. You can also trigger generation manually:

use Crumbls\Timeline\Services\OccurrenceGenerator;

$generator = app(OccurrenceGenerator::class);

// Generate from now to the configured horizon (default 12 months)
$generator->generate($rule);

// Generate a custom range
$generator->generateRange(
    $rule,
    Carbon::parse('2025-01-01'),
    Carbon::parse('2025-12-31')
);

The generator will:

  1. Expand the RRULE into concrete dates within the window
  2. Create new Occurrence records for dates not already present
  3. Update ends_at on existing Occurrence records if the Rule changed
  4. Delete future Scheduled occurrences that are no longer in the RRULE expansion
  5. Leave Cancelled and Completed occurrences untouched

Queue Setup

The package dispatches GenerateOccurrencesJob on the default queue. For production, ensure your queue worker is running:

php artisan queue:work

For scheduled maintenance (regenerating the rolling 12-month window), add this to your routes/console.php or scheduler:

use Crumbls\Timeline\Models\Rule;
use Crumbls\Timeline\Jobs\GenerateOccurrencesJob;

Schedule::call(function () {
    Rule::where('is_active', true)->each(function (Rule $rule) {
        GenerateOccurrencesJob::dispatch($rule);
    });
})->monthly();

Building RRULEs with RRuleBuilder

Rather than writing RRULE strings by hand, use the fluent RRuleBuilder to construct them:

use Crumbls\Timeline\Support\RRuleBuilder;

Common patterns

// Every Tuesday
RRuleBuilder::make()->weekly()->onDays('TU')->toString();
// FREQ=WEEKLY;BYDAY=TU

// Every weekday
RRuleBuilder::make()->weekly()->onDays('MO', 'TU', 'WE', 'TH', 'FR')->toString();
// FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR

// Every other Monday
RRuleBuilder::make()->weekly()->every(2)->onDays('MO')->toString();
// FREQ=WEEKLY;INTERVAL=2;BYDAY=MO

// First Friday of the month
RRuleBuilder::make()->monthly()->onNthWeekday(1, 'FR')->toString();
// FREQ=MONTHLY;BYDAY=1FR

// Last Monday of the month
RRuleBuilder::make()->monthly()->onNthWeekday(-1, 'MO')->toString();
// FREQ=MONTHLY;BYDAY=-1MO

// 15th of every month
RRuleBuilder::make()->monthly()->onMonthDay(15)->toString();
// FREQ=MONTHLY;BYMONTHDAY=15

// Last day of every month
RRuleBuilder::make()->monthly()->onMonthDay(-1)->toString();
// FREQ=MONTHLY;BYMONTHDAY=-1

// Daily, 10 times then stop
RRuleBuilder::make()->daily()->count(10)->toString();
// FREQ=DAILY;COUNT=10

// Weekly until a specific date
RRuleBuilder::make()->weekly()->onDays('TU')->until('2025-12-31')->toString();
// FREQ=WEEKLY;BYDAY=TU;UNTIL=20251231T235959Z

// Every June 15th
RRuleBuilder::make()->yearly()->inMonth(6)->onMonthDay(15)->toString();
// FREQ=YEARLY;BYMONTHDAY=15;BYMONTH=6

Using the result

Pass the output directly to a Rule:

Rule::create([
    'event_id'  => $event->id,
    'starts_at' => '2025-06-03 18:00:00',
    'ends_at'   => '2025-06-03 20:00:00',
    'rrule'     => RRuleBuilder::make()->weekly()->onDays('TU')->toString(),
]);

The builder also casts to string automatically:

'rrule' => (string) RRuleBuilder::make()->monthly()->onNthWeekday(1, 'FR'),

Human-readable descriptions

// From a builder instance
RRuleBuilder::make()->monthly()->onNthWeekday(1, 'FR')->toHuman();
// "monthly on the first Friday"

// From an existing RRULE string
RRuleBuilder::describe('FREQ=WEEKLY;BYDAY=MO,WE,FR');
// "weekly on Monday, Wednesday and Friday"

Use toHuman() / describe() to display schedules to users without exposing the raw RRULE syntax.

Parsing existing RRULEs

If you have a stored RRULE string and want to modify it:

$builder = RRuleBuilder::parse('FREQ=WEEKLY;BYDAY=TU');
$builder->count(10);

echo $builder->toString();
// FREQ=WEEKLY;BYDAY=TU;COUNT=10

The RRULE: prefix is stripped automatically if present.


RRULE Reference

For cases where you write RRULE strings directly, store only the property value — omit the RRULE: prefix.

Schedule RRULE
Daily FREQ=DAILY
Weekly on Tuesday FREQ=WEEKLY;BYDAY=TU
Every weekday FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
First Friday of the month FREQ=MONTHLY;BYDAY=1FR
Last day of the month FREQ=MONTHLY;BYMONTHDAY=-1
Every other week on Monday FREQ=WEEKLY;INTERVAL=2;BYDAY=MO
Annually FREQ=YEARLY
10 occurrences then stop FREQ=WEEKLY;COUNT=10

For one-time events, leave rrule null. The generator creates a single Occurrence at starts_at.