Why JavaScript Calendar Libraries Fail Here
The go-to solution for a calendar has been a JavaScript widget: FullCalendar, Pikaday, flatpickr, or one of a dozen others. These libraries own the DOM. They render their own markup, manage their own state, and respond to user interaction by mutating the DOM directly.
In a Livewire or Live Component context, that is a fundamental conflict.
When the user clicks "next month" in a JS calendar, the library updates its internal state and re-renders its own markup inside the DOM. The server knows nothing about it. Now imagine that the same component has a server-side prop that also needs to update — say, a list of events below the calendar. The moment Livewire re-renders the component, it replaces the DOM subtree that the JS library was managing. The library's state is gone. The calendar snaps back to whatever month the server thinks it is. You are now fighting over who owns the DOM, and neither side wins cleanly.
You can work around this with Stimulus controllers and careful event wiring, but every workaround adds complexity. You end up maintaining a hidden server-side date state that mirrors the JS calendar state, synchronizing them on every interaction, and hoping that morphdom or Alpine's reactivity does not blow up your carefully wired event listeners in the process. I have seen teams spend days on this. The result is always brittle.
The core problem is architectural: JS calendar libraries were designed for a world where the client owns the state. They predate the server-side component renaissance entirely.
There Was Never a Server-Side Calendar Library Worth Using
This is not a gap that was overlooked — it was simply never needed. Before Livewire and Live Components, a calendar rendered on the server was a dead object. You could generate HTML, but then what? The user clicks "next month" and you need a full page reload or a custom AJAX endpoint just to move one month forward. Nobody wanted that in 2015 when FullCalendar was slick and fast.
So the ecosystem never built serious server-side calendar infrastructure. There are scattered snippets that generate a month grid, but they stop there. No data attachment, no disabled day logic, no composable renderer — just a raw array of dates, maybe wrapped in a <table>. Useless for anything real.
The problem only became urgent once Live Components arrived and made server-side rendering genuinely competitive for interactive UIs.
The Case for Going Fully Server-Side
The obvious objection is latency. If every user interaction requires a server round-trip, won't users notice?
In practice, no — provided the application is reasonably optimized. Modern infrastructure serves responses in 20–80ms for cached or simple queries. The perceived interaction speed in Livewire or UX Live is comparable to a well-written Vue component for most interactions. The Livewire team has published benchmarks; the UX Live docs show the same. This is not a theoretical argument anymore — production apps are proving it.
More importantly: if your server is slow, that is a problem you need to solve regardless of whether you are using server-side components. Caching, query optimization, and proper infrastructure are not optional in a production application. Server-side components make the performance characteristics explicit and measurable instead of hidden inside a black-box JS bundle.
The actual benefits of going fully server-side for a calendar are substantial:
No hydration. The server renders the calendar on every request. There is no state to sync between server and client because there is only one source of truth.
No JS at all for navigation. Clicking "next month" calls a server action. The component re-renders with the new month. Done.
Data loading is trivial. You query your database in PHP, attach data to each day, and render it. No separate API endpoint, no JSON serialization contract to maintain, no loading spinner while the client fetches events.
The component is testable. Pure PHP. You can unit test the calendar logic, the data loader, and the rendered output without a browser.
php-calendar: A Library Built for This
tito10047/php-calendar is a pure PHP library designed from the ground up for server-side rendering in Symfony and Laravel. It has no frontend dependencies. It generates a structured data model that your template renders — either through its built-in HTML renderer or directly via Twig or Blade.
The central object is Calendar, an immutable value object. Every mutation — disabling days, loading data, navigating months — returns a new instance. This fits perfectly with how Live Components work: each re-render constructs a fresh Calendar from the current props, and Symfony or Laravel handles the rest.
Install it:
composer require tito10047/php-calendar
Building the Live Component
Here is a real implementation. The calendar is a Symfony UX Live Component. It has one LiveProp — the current date — which survives re-renders and is passed back to the server on every interaction.
#[AsLiveComponent('Forum:Scheduler:Calendar', template: 'Forum/Scheduler/Calendar.html.twig')]
class Calendar
{
use DefaultActionTrait;
use ComponentToolsTrait;
private \Tito10047\Calendar\Calendar $calendar;
#[LiveProp()]
public \DateTimeImmutable $date;
public ?\DateTimeInterface $selectedDate = null;
public function __construct(private readonly CalendarDataReader $calendarDataReader) {}
#[PostMount]
public function postMount(): void
{
$this->date = $this->selectedDate ?? $this->date ?? new \DateTimeImmutable();
$this->postHydrate();
}
#[PostHydrate]
public function postHydrate(): void
{
$this->calendar = new \Tito10047\Calendar\Calendar(
date: $this->date,
daysGenerator: CalendarType::Monthly,
startDay: DayName::Monday,
dataLoader: $this->calendarDataReader,
);
}
public function getCurrentMonth(): \DateTimeInterface
{
return $this->calendar->getDate();
}
/** @return Day[][] */
public function getDays(): array
{
return $this->calendar->getDaysTable();
}
#[LiveAction]
public function nextMonth(): void
{
$this->calendar = $this->calendar->nextMonth();
$this->date = $this->calendar->getDate();
}
#[LiveAction]
public function prevMonth(): void
{
$this->calendar = $this->calendar->prevMonth();
$this->date = $this->calendar->getDate();
}
#[LiveAction]
public function select(#[LiveArg] string $date): void
{
$this->emit('daySelected', ['date' => $date]);
}
#[LiveAction]
public function today(): void
{
$this->date = new \DateTimeImmutable();
$this->postMount();
}
}
A few things worth noting here.
#[PostHydrate] rebuilds the Calendar object every time the component hydrates from the serialized LiveProp. This is the correct place to do it — $this->date is guaranteed to be populated from the stored state at that point. #[PostMount] handles the initial mount where $selectedDate can override the default date.
nextMonth() and prevMonth() are #[LiveAction] methods. When the user clicks the navigation buttons, UX Live sends an AJAX request, calls the method, and re-renders the component. No JavaScript involved. The new $this->date is automatically serialized and stored in the component's state for the next request.
The select() action emits an event that parent components can listen to. This is how the calendar communicates the selected date upward — again, pure server-side event system.
The Data Loader
The CalendarDataReader implements DayDataLoaderInterface. The library calls load() once with the full date range visible in the calendar, then calls getData() for each individual day cell.
class CalendarDataReader implements DayDataLoaderInterface
{
private array $data;
public function __construct(private readonly CalendarService $calendarService) {}
public function load(\DateTimeImmutable $from, \DateTimeImmutable $to): void
{
$this->data = $this->calendarService->getData($from, $to, [
CalendarEventType::FreeDay,
CalendarEventType::Services,
CalendarEventType::TicketCount,
]);
}
public function getData(\DateTimeImmutable $date): array
{
$key = $date->format('Y-m-d');
return $this->data[$key] ?? [
'pocet' => 0,
'freeDay' => false,
];
}
}
load() is called once. That is important — you bulk-load all data for the visible date range in a single query, then serve individual day lookups from memory. This is the correct pattern: one query per render, not one query per cell.
The Twig Template
The template receives this.days — a Day[][] keyed by ISO week number and day number 1–7. The Day object carries ghost, today, enabled, date, and data. Everything you need to render the cell is on the object.
<div {{ attributes.defaults({ class: 'mini-calendar mb-4' }) }}>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small font-weight-bold text-uppercase">
{% set firstDay = this.currentMonth %}
{{ firstDay|date('F')|trans ~ ' ' ~ firstDay|date('Y') }}
</span>
<div class="btn-group btn-group-sm">
<button class="btn btn-primary btn-xs" {{ live_action('prevMonth') }}>«</button>
<button class="btn btn-primary btn-xs"
data-action="live#emit"
data-live-event-param="prevDay">‹</button>
<button class="btn btn-primary btn-xs"
data-action="live#emit"
data-live-event-param="today">{{ 'Today'|trans }}</button>
<button class="btn btn-primary btn-xs"
data-action="live#emit"
data-live-event-param="nextDay">›</button>
<button class="btn btn-primary btn-xs" {{ live_action('nextMonth') }}>»</button>
</div>
</div>
<table class="table table-sm table-borderless text-center small mb-0 text-muted">
<thead>
<tr>
<th class="text-muted font-weight-normal"></th>
<th class="font-weight-normal">{{ 'Mon'|trans }}</th>
<th class="font-weight-normal">{{ 'Tue'|trans }}</th>
<th class="font-weight-normal">{{ 'Wed'|trans }}</th>
<th class="font-weight-normal">{{ 'Thu'|trans }}</th>
<th class="font-weight-normal">{{ 'Fri'|trans }}</th>
<th class="font-weight-normal">{{ 'Sat'|trans }}</th>
<th class="font-weight-normal">{{ 'Sun'|trans }}</th>
</tr>
</thead>
<tbody>
{% for weekNum, days in this.days %}
<tr>
<td class="text-muted px-0">{{ weekNum }}</td>
{% for i in 1..7 %}
{% set day = days[i] ?? null %}
<td class="p-0 text-center">
{% if day %}
<button
class="calendar-day-btn btn btn-xs rounded-circle
{{ day.ghost ? 'text-muted' : '' }}{{ day.today ? 'bg-primary text-white' : '' }}{{ day.data.freeDay ? 'muted' : '' }}{{ day.date|date('Ymd') == selectedDate|date('Ymd') ? 'bg-secondary text-white' : '' }}"
data-action="live#emit"
data-live-event-param="daySelected"
data-live-date-param="{{ day.date|date('d-m-Y') }}"
>
{{ day.date|date('j') }}
</button>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ live_action('prevMonth') }} generates the data-action and related attributes that tell UX Live to call the prevMonth action on the component. The rest of the navigation buttons use live#emit to emit events that the parent Scheduler component listens to — this is how day selection and single-day navigation is delegated upward.
The selectedDate comparison in the class list is straightforward: format both dates as Ymd strings and compare. Server-side. No reactive binding, no two-way sync, no watchers. Just a conditional class on a string comparison.
Using It Inside a Parent Component
The calendar is embedded inside a larger Scheduler component as a Twig component tag:
<twig:Forum:Scheduler:Calendar :selectedDate="date" id="selected-{{ date|date('YMD') }}"/>
The parent passes selectedDate as a prop. When the user selects a day, the calendar emits daySelected, and the parent updates its own date prop accordingly. The Scheduler then re-renders with the new date, which is passed back down to the Calendar as selectedDate. The loop is closed entirely server-side.
What You Do Not Write
To be explicit about what is missing from this implementation: there is no JavaScript state management, no event listener setup and teardown, no hydration mismatch debugging, no onMounted hook that re-initializes a widget after every server render, no hidden <input> that mirrors the JS library's selected date back to the server, and no custom AJAX endpoint that the JS calendar calls to fetch events.
The entire calendar — navigation, day selection, event data loading, selected state, today marker, ghost days — is handled in about 90 lines of PHP and a Twig template. The Symfony DI container injects CalendarDataReader, which queries the database. The library handles the date math and ghost day filling. UX Live handles the re-rendering on interaction.
Closing Thought
The server-side component approach is not a regression to the era of full page reloads. It is a different architecture with different trade-offs — and for data-heavy, stateful UIs like schedulers, those trade-offs are increasingly favorable. The main thing that was missing was proper server-side infrastructure for calendar rendering. That gap is now filled.
The library: github.com/tito10047/php-calendar