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:
- Expand the RRULE into concrete dates within the window
- Create new
Occurrencerecords for dates not already present - Update
ends_aton existingOccurrencerecords if the Rule changed - Delete future
Scheduledoccurrences that are no longer in the RRULE expansion - Leave
CancelledandCompletedoccurrences 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.