Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Livewire Calendar Laravel Package

asantibanez/livewire-calendar

Livewire Calendar is a Laravel Livewire component for rendering monthly calendars with events. It supports day and event clicks, custom event content, and easy integration with Eloquent models, making it simple to add interactive scheduling to Livewire apps.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm version alignment. The package tracks Livewire majors, so let Composer resolve it against your existing install rather than pinning blindly:

composer require asantibanez/livewire-calendar
php artisan vendor:publish --tag=livewire-calendar-views   # optional, see Gotchas

Run composer show asantibanez/livewire-calendar and check the README/CHANGELOG for the matching Livewire major. A Livewire 2 app and a Livewire 3 app will not use the same package version. On Laravel 12, require ^2.3 — Laravel 12 support landed in 2.3.0, so older tags will fail dependency resolution or composer update on a freshly upgraded app.

The core idea: your component is the calendar. You don't drop a widget into a view and feed it data — you subclass the package component and answer one question: "what events belong on this grid?"

php artisan make:livewire AppointmentsCalendar
namespace App\Livewire;

use Asantibanez\LivewireCalendar\LivewireCalendar;
use Illuminate\Support\Collection;

class AppointmentsCalendar extends LivewireCalendar
{
    public function events(): Collection
    {
        return Appointment::query()
            ->whereBetween('scheduled_at', [$this->gridStartsAt, $this->gridEndsAt])
            ->get()
            ->map(fn (Appointment $a) => [
                'id'          => $a->id,
                'title'       => $a->customer_name,
                'description' => $a->service,
                'date'        => $a->scheduled_at,
            ]);
    }
}

Render it wherever you'd render any Livewire component:

<livewire:appointments-calendar />
{{-- or as a full page: Route::get('/calendar', AppointmentsCalendar::class); --}}

That is a working month calendar in about a dozen lines. title and description are drawn in the default event chip; id and date are required for grouping and click handling.

Where to look first, in this order:

  1. src/LivewireCalendar.php — the public properties ($year, $month, $weekStartsAt, $dayNames, $eventView, $beforeCalendarView, $afterCalendarView, $noEventsText) and the two hook methods onDayClick(), onEventClick(). This file is the API surface.
  2. resources/views/calendar.blade.php, month.blade.php, event.blade.php — the markup you'll be overriding 80% of the time.
  3. The README's "Customization" section for the view-swapping convention.

First real use case — a booking/scheduling screen: month grid with per-day appointment chips, clicking a day opens a "new appointment" modal, clicking a chip opens the edit modal. Both flows are implemented by overriding two methods, covered next.

Implementation Patterns

Scope the query to the visible grid, always. events() runs on every render — every month navigation, every click, every wire:model update. gridStartsAt and gridEndsAt are already computed for the full weeks shown, so use them:

->whereBetween('scheduled_at', [$this->gridStartsAt->startOfDay(), $this->gridEndsAt->endOfDay()])

Note the grid spans complete weeks: it includes trailing days from the previous month and leading days from the next. Events on those padding days render too, so scoping to the month (startOfMonth/endOfMonth) silently drops visible events.

Ship a custom event chip, not a custom calendar. Declare the view property on your subclass; no method overrides needed:

public $eventView = 'livewire.appointments.event';
{{-- resources/views/livewire/appointments/event.blade.php --}}
<div class="rounded px-1 py-0.5 text-xs {{ $event['status'] === 'confirmed' ? 'bg-green-100' : 'bg-amber-100' }}">
    <span class="font-semibold">{{ $event['date']->format('H:i') }}</span>
    {{ $event['title'] }}
</div>

Any extra keys you put in the event array (status, color, url, assignee) are passed straight through to this view — that's the intended extension point. Use it instead of touching month.blade.php.

Use beforeCalendarView / afterCalendarView for toolbars and filters. Because these views render inside your own component, wire:model binds to your public properties directly:

public $eventView = 'livewire.appointments.event';
public $beforeCalendarView = 'livewire.appointments.toolbar';
public $filterAssignee = null;
<div class="flex gap-2 mb-4">
    <select wire:model.live="filterAssignee">
        <option value="">Everyone</option>
        @foreach ($assignees as $id => $name)<option value="{{ $id }}">{{ $name }}</option>@endforeach
    </select>
    <button wire:click="goToCurrentMonth">Today</button>
    <button wire:click="$dispatch('open-appointment-modal')">New</button>
</div>

Then read $this->filterAssignee inside events(). This is the cleanest way to add filters, a "Today" button, and search without forking any markup.

Override the click hooks instead of registering listeners.

public function onDayClick($year, $month, $day)
{
    $this->dispatch('open-appointment-modal', date: "{$year}-{$month}-{$day}");
}

public function onEventClick($eventId)
{
    $this->dispatch('open-appointment-modal', appointmentId: $eventId);
}

The base class wires wire:click to these methods; registering $listeners = ['onEventClick'] is not how this package works and will confuse you during debugging. If you override mount() to set defaults, call parent::mount() first, or the initial $year/$month won't be set and the grid renders empty.

Refresh after CRUD via an event, not a reload. If your modal is a sibling Livewire component, have it dispatch something your calendar already listens for — any received event re-renders the component, which re-invokes events() and pulls fresh data:

#[On('appointment-saved')]   // Livewire 3; use $listeners on v2
public function refreshGrid() {}

Note that events() itself is the refresh hook — don't cache the collection on a public property or you'll be fighting staleness.

Deep-link the visible month. $year and $month are public, so expose them in the query string and the back button / bookmarkable URLs start working:

protected $queryString = ['year', 'month'];   // works on both v2 and v3

To honor a ?month=2025-03 on first load, set them in mount() after parent::mount().

Multi-tenant or per-user calendars are just another where() in events(). There's no global state — two calendars on one page are two independent components with independent $year/$month. Just ensure event ids are unique per instance if you use wire:key in the chip view.

Testing is ordinary Livewire testing. The public properties are assertions, and navigation is a method call:

Livewire::test(AppointmentsCalendar::class)
    ->assertSee('Team standup')
    ->call('goToNextMonth')
    ->assertSet('month', now()->addMonthNoOverflow()->month);

Seed with Carbon::setTestNow() so gridStartsAt and the seeded events agree — otherwise month-boundary tests flake.

Gotchas and Tips

Tailwind won't style it out of the box. The shipped views use Tailwind classes that live in vendor/, which your Tailwind build doesn't scan by default. Add the path explicitly:

// tailwind.config.js (v3)
content: [/* ... */ './vendor/asantibanez/livewire-calendar/resources/views/**/*.blade.php'],
/* Tailwind 4 */
@source "./vendor/asantibanez/livewire-calendar/resources/views";

Symptom if you skip this: a structurally correct but completely unstyled grid, which people usually misdiagnose as a broken component.

Timezone drift is the #1 "wrong day" bug. Grid cells are grouped by date in the app's timezone; store and compare consistently. If events are persisted in UTC and displayed in a user's timezone, the date you return from events() must already be converted, or a 23:00 UTC appointment shows on the wrong cell. Convert in the mapper, not in the view.

id must be unique and stable, and mind the type. Duplicate ids break wire:key and make onEventClick ambiguous. Also, ids arrive in the hook as whatever the blade interpolated — cast defensively (Appointment::findOrFail($eventId)) rather than assuming an int or a string.

Don't override render() casually. The parent render() is what passes the grid and events to the view. If you need a layout, chain ->layout('layouts.app'), or render your calendar component from a wrapper Blade file. Replacing the view with a hand-rolled one means re-implementing the data contract.

Publish views before editing them. php artisan vendor:publish --tag=livewire-calendar-views copies to resources/views/vendor/livewire-calendar/, which Laravel's view namespace prefers over the package's copy. Editing vendor/ directly is lost on the next composer update — a common way to get a "why did my styling disappear after deploy" incident. Better still: prefer eventView/beforeCalendarView overrides so you own as little markup as possible.

Per-day ordering is whatever order you return. Sorting inside a day is your job: ->sortBy('scheduled_at') before ->map() if you want chronological chips. The grid does not sort for you.

No drag-and-drop, no recurrence, no week/day view, month only. Recurring appointments must be expanded into concrete instances inside events(). Drag-to-reschedule requires a separate JS layer (SortableJS or similar) and your own persistence endpoint. If the feature list includes a week view, this package is the wrong shape — budget for it up front rather than discovering it mid-sprint.

Performance is your query's performance. The component renders the whole month eagerly; there's no pagination or virtualization. with('customer') and column selection matter, and a calendar that's fast in dev will crawl once a tenant has 20k rows — the whereBetween on gridStartsAt/gridEndsAt is not optional.

Empty state: noEventsText (default "No events") covers the month-is-empty case. Set it per-instance for localization rather than editing the Blade.

Debugging clicks: if onDayClick isn't firing, check that you haven't shadowed the base method signature with a different arity, and that your component isn't nested inside another component that also defines a same-named method. Livewire's event bubbling is where these go wrong.

Upgrading to 2.3.0 is a no-op for your code. The release only widens the framework constraint to include Laravel 12 — no property, hook, view, or event-array changes. Bump the lock file, re-run vendor:publish --tag=livewire-calendar-views --force only if you've forked the shipped views and want the (unchanged) upstream copies, and re-run your calendar tests. If you've pinned ~2.2 on a Laravel 12 upgrade, that's the one thing to fix.

Bus factor: single-maintainer package, ~970 stars, but recent releases — practical risk is low, and because your integration surface is one subclass plus an event array, forking or replacing it later is a day's work, not a rewrite. Keep your mapping logic in events() and your presentation in your own Blade views, and you stay portable.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core