Building a Calendar Events Feature with Laravel
How I built a full calendar feature in Laravel from scratch, handling recurrence rules, timezones, and date range queries end to end.
Last month, one of the projects I was working on required a calendar feature. Users needed to create events, those events could repeat on a schedule (every Monday, the first day of every month, and so on), and users logging in from different timezones all had to see the correct times. It sounds straightforward, but when you combine dates, recurrence rules, and timezones, you end up with far more decision points than you’d expect.
Designing the data structure
The first thing I did was figure out how to store events. There are two common approaches for recurring events: store each occurrence as a separate row, or store a single rule record and compute the occurrences on the fly.
The first approach makes queries simple, but pre-generating thousands of rows for far-future occurrences is wasteful. The second approach requires less storage but adds complexity to the computation layer.
My decision: one row per event, with the recurrence rule stored in a JSON column, and occurrences calculated at render time. This wasn’t a large-scale application, so that approach was more than sufficient.
The migration ended up looking like this:
<?php
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('title');
$table->text('description')->nullable();
$table->datetime('starts_at');
$table->datetime('ends_at');
$table->string('timezone', 64)->default('UTC');
$table->json('recurrence')->nullable();
$table->timestamps();
});
If the recurrence column is null, the event does not repeat. When it has a value, it holds a structure like {"frequency": "weekly", "until": "2018-12-31"}.
The timezone problem
Timezone handling was the most frustrating part of this project. I always store datetimes in UTC in the database — that’s a hard rule. But when displaying to the user I need to convert to their local timezone.
Laravel’s Carbon library handles most of this cleanly:
<?php
$event->starts_at // comes in as UTC, Carbon instance
// Convert to the user's timezone
$localTime = $event->starts_at->setTimezone($user->timezone);
// For display
echo $localTime->format('d M Y, H:i');
The tricky part: users fill in forms using their local time. I need to convert that value to UTC before writing it to the database:
<?php
$localStartsAt = Carbon::createFromFormat(
'Y-m-d H:i',
$request->starts_at,
$request->user()->timezone
);
$event->starts_at = $localStartsAt->utc();
You can do this conversion during request validation or when constructing the model. I initially put it in the controller; later, moving that logic into a form request class felt much cleaner.
Computing recurring events
To render a calendar view I need to fetch all events that fall within a given date range. Non-recurring events are a straightforward query. For recurring ones, I needed a small computation layer.
A simple example: finding all dates within a specific month for a weekly recurring event.
<?php
function expandRecurringEvent(Event $event, Carbon $from, Carbon $to): array
{
$occurrences = [];
$recurrence = $event->recurrence;
if (!$recurrence || $recurrence['frequency'] !== 'weekly') {
return [$event->starts_at];
}
$current = $event->starts_at->copy();
$until = isset($recurrence['until'])
? Carbon::parse($recurrence['until'])
: $to;
while ($current->lte($until) && $current->lte($to)) {
if ($current->gte($from)) {
$occurrences[] = $current->copy();
}
$current->addWeek();
}
return $occurrences;
}
This is the bare minimum; in the real world, as you add monthly, yearly, and day-specific recurrence rules, this logic grows quickly. At some point it is worth evaluating an external package that supports iCalendar rules alongside nesbot/carbon, but the scope here was limited enough to keep things simple.
The calendar view
On the frontend I chose FullCalendar rather than writing a calendar component from scratch. The library expects events in JSON format, so all I needed was a Laravel API endpoint that returns events for the requested range:
<?php
public function index(Request $request)
{
$from = Carbon::parse($request->start);
$to = Carbon::parse($request->end);
$events = Event::whereBetween('starts_at', [$from, $to])
->get()
->map(function ($event) {
return [
'id' => $event->id,
'title' => $event->title,
'start' => $event->starts_at->toIso8601String(),
'end' => $event->ends_at->toIso8601String(),
];
});
return response()->json($events);
}
Lessons learned
The place I lost the most time was timezone conversion. Early on I wrote local times directly to the database, the calendar started showing wrong hours, and I had to go back and fix everything. Getting “always store UTC in the database” right from the beginning is essential.
The recurring event design also required more up-front thinking than I expected. When does a recurrence end? Does the event’s start time account for Daylight Saving Time (DST) when occurrences are calculated? Asking these questions at the design stage will save you a significant amount of time that would otherwise go into fixing bugs later.
A calendar feature sounds simple, but it has a few corners that demand careful attention. When everything finally clicks into place and works correctly, though, it is genuinely satisfying.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.