<x-widget.calendar>
Calendar Blade component for Laravel and Livewire
Events by month, week or day: bookings, appointments, shifts, a team's leave. Multi-day events run as bars, overlapping ones sit side by side, busy days get a "more" list, and on a phone it becomes an agenda. All-day events keep their dates in any timezone; times show in the timezone you give, and ends are exclusive. views adds a switch between views, and the arrows change the period without reloading the page. Pass arrays, or your models with keys. Read-only: events can link to your pages. Works with Livewire.
php artisan bladewell:add calendar
- Also adds
- Button, Icon, Popover, Tooltip cursor
- Needs PHP extension
intl- Works with
- Laravel 12 and 13, Livewire 3 and 4, Tailwind CSS 4.1+, PHP 8.3+
Usage
Loading events
Load what's shown overlaps, not only what starts in it: a booking from the 28th to the 3rd belongs on both months. range() gives the same days the calendar draws, from the same ?date=, view, min and max. Only the person's own events, from the query: everything passed to the calendar is in the page.
Route::get('/bookings', function (Request $request) {
$timezone = $request->user()->timezone ?? config('app.timezone');
// With views on the calendar, the view is in the address too; anything else is the month, as the calendar has it.
$view = in_array($request->query('view'), ['month', 'week', 'day'], true) ? $request->query('view') : 'month';
[$from, $to] = Calendar::range($request->query('date'), $timezone, $view, min: '2026-01', max: now()->addYear());
$bookings = $request->user()->bookings()
->where('starts_at', '<', $to)
->where('ends_at', '>=', $from)
->with('room')
->get();
return view('bookings.index', ['bookings' => $bookings, 'timezone' => $timezone]);
})->name('bookings.index');
// bookings/index.blade.php: the same min and max as above. The calendar reads ?view= and ?date= itself, as its links set
// it. keys says where each field is on a Booking, so the models go in as they are: a name, a dotted path, or a closure.
// <x-widget.calendar
// label="Bookings"
// :timezone="$timezone"
// views="month,week,day"
// :events="$bookings"
// :keys="[
// 'title' => 'room.name',
// 'start' => 'starts_at',
// 'end' => 'ends_at',
// 'href' => fn ($booking) => route('bookings.show', $booking),
// 'status' => fn ($booking) => $booking->confirmed ? null : 'Awaiting confirmation',
// ]"
// min="2026-01"
// :max="now()->addYear()"
// />
// All-day events are dates: keep them in date columns and pass Y-m-d strings (or the date casts), with the end the day
// after the last day. One stored as a moment has to be turned into its date where it happens first
// ($event->starts_at->setTimezone($timezone)->toDateString()): the calendar turns down an all-day date with a time
// rather than guess its day. Overlap is the same test on dates:
// ->where('starts_on', '<', $to->toDateString())->where('ends_on', '>', $from->toDateString())
Livewire
In a Livewire component, keep the date in a property (#[Url] public string $date = '';, so it stays in the address) and listen for the links with wire:calendar-navigate: the calendar leaves the page where it is, and the render that follows draws the new period. url gives the links a real address too, for opening one in a new tab. Load the events for $date with Calendar::range(), as without Livewire. The today mark stays on the viewer's today through every render.
<x-widget.calendar
label="Bookings"
:timezone="$timezone"
:events="$this->bookings"
:date="$date"
:url="route('bookings.index', ['date' => '{date}'])"
wire:calendar-navigate="$set('date', $event.detail.date)"
/>
On the server
The calendar can't stop a request that skips it. Here's what to check on your server, and what to keep cheap.
Security
-
Everything passed to the calendar is in the page, whether it shows or not. Load only the events the person may see, filtered in the query (a policy or a scope), never by hiding them in the view, and leave private details out of the title on a calendar others can see. Titles and statuses are escaped, and an
hrefthat isn't a path or an http, https, mailto or tel address (such asjavascript:) is dropped, so events people enter themselves can be shown as they are.Laravel: Authorization · OWASP: Insecure direct object references
Server cost
-
The date comes from the URL, so anyone can ask for any month, week or day. Load only what's shown, with
Calendar::range()and an index on the start and end columns, and giveminandmaxwhere dates outside them mean nothing, so a crawler following Next doesn't walk on for ever.
Examples
Month
A team's calendar, with buttons for month, week and day. They, Today and the arrows are links that set ?view= and ?date=, which the calendar reads back; the script follows them without loading the page again. Kept between last month and two months ahead; the server loads what's shown. In the month, a timed event shows its time, a dot in its colour and its title; one that runs past midnight, or all day, is a bar across its days. An event can link to its page.
Pass
$events
from your controller.
November 2026
Times in United Kingdom Time
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
-
Monday, November 2, 2026
-
9:30 AM – 11:00 AM
Sprint planning
-
-
Wednesday, November 4, 2026
-
2:00 PM – 3:00 PM
Design review
-
-
Monday, November 9, 2026
-
8:00 AM – 10:00 AM
Order #1042 delivery Confirmed
-
-
Wednesday, November 11, 2026
-
All day
Company offsite
All day, Wed, Nov 11 to Fri, Nov 13
-
-
Thursday, November 12, 2026
-
All day
Company offsite
All day, Wed, Nov 11 to Fri, Nov 13
-
-
Friday, November 13, 2026
-
All day
Company offsite
All day, Wed, Nov 11 to Fri, Nov 13
-
-
Sunday, November 15, 2026
-
4:00 PM – 4:30 PM
Customer call: Weber GmbH Tentative
-
-
Wednesday, November 18, 2026
-
12:00 PM
Release
-
-
Saturday, November 21, 2026
-
From 11:00 PM
Server maintenance Planned
Sat, Nov 21, 11:00 PM to Sun, Nov 22, 2:00 AM
-
-
Sunday, November 22, 2026
-
Until 2:00 AM
Server maintenance Planned
Sat, Nov 21, 11:00 PM to Sun, Nov 22, 2:00 AM
-
-
Wednesday, November 25, 2026
-
3:00 PM – 4:00 PM
Retro
-
Show code Hide code
<x-widget.calendar
label="Team calendar"
timezone="Europe/London"
views="month,week,day"
:events="$events"
:min="now()->subMonth()"
:max="now()->addMonths(2)"
/>
Week
A week as hours, opening at 7:00, or earlier when an event starts earlier; the rest of the day scrolls. All-day events, and those of a day or more, run along the top. Events that start together sit side by side; one that starts during another is drawn over the rest of it, set in a little, so both titles can be read. One over midnight is drawn on both days, and a line marks the current time. Where there's no room for seven columns, as on a phone, it's a list of the week's days.
Pass
$events
from your controller.
Nov 9 – Nov 15, 2026
Times in United Kingdom Time
| Monday, November 9, 2026 | Tuesday, November 10, 2026 | Wednesday, November 11, 2026 | Thursday, November 12, 2026 | Friday, November 13, 2026 | Saturday, November 14, 2026 | Sunday, November 15, 2026 | |
|---|---|---|---|---|---|---|---|
| All day |
|
|
|
|
- Stakeholder meeting , 10:00 AM to 11:30 AM
- Online conference , 11:00 AM to 11:15 AM
- Quick call with Martin , 11:00 AM to 12:15 PM
- Product team , 9:00 AM to 10:30 AM
- Interview: Priya
- Coffee , 10:00 AM to 10:20 AM
- Stand-up , 9:00 AM to 9:05 AM
- Deploy window , Thu, Nov 12, 10:00 PM to Fri, Nov 13, 1:30 AM, Planned
- Deploy window , Thu, Nov 12, 10:00 PM to Fri, Nov 13, 1:30 AM, Planned
- Early flight , 5:45 AM to 7:10 AM
-
Monday, November 9, 2026
-
10:00 AM – 11:30 AM
Stakeholder meeting
-
11:00 AM – 11:15 AM
Online conference
-
-
Tuesday, November 10, 2026
-
11:00 AM – 12:15 PM
Quick call with Martin
-
-
Wednesday, November 11, 2026
-
9:00 AM – 10:30 AM
Product team
-
9:30 AM – 10:15 AM
Interview: Priya Confirmed
-
10:00 AM – 10:20 AM
Coffee
-
-
Thursday, November 12, 2026
-
All day
Team offsite
All day, Thu, Nov 12 to Fri, Nov 13
-
9:00 AM – 9:05 AM
Stand-up
-
From 10:00 PM
Deploy window Planned
Thu, Nov 12, 10:00 PM to Fri, Nov 13, 1:30 AM
-
-
Friday, November 13, 2026
-
All day
Team offsite
All day, Thu, Nov 12 to Fri, Nov 13
-
Until 1:30 AM
Deploy window Planned
Thu, Nov 12, 10:00 PM to Fri, Nov 13, 1:30 AM
-
5:45 AM – 7:10 AM
Early flight
-
-
Saturday, November 14, 2026
-
All day
Friends visiting
All day, Sat, Nov 14 to Tue, Nov 17
-
-
Sunday, November 15, 2026
-
All day
Friends visiting
All day, Sat, Nov 14 to Tue, Nov 17
-
Show code Hide code
<x-widget.calendar
label="My week"
timezone="Europe/London"
view="week"
week-start="1"
:events="$events"
param="week"
:min="now()->subWeeks(2)"
:max="now()->addWeeks(4)"
/>
Day
One day as hours, which fits a phone: an appointment book, a room's bookings. Previous and Next move a day at a time.
Pass
$events
from your controller.
Thursday, November 12, 2026
Times in New York Time
| Thursday, November 12, 2026 | |
|---|---|
| All day |
|
- Room 2: Physiotherapy , 8:00 AM to 9:00 AM
- Room 1: Check-up , 8:30 AM to 9:00 AM
- Room 1: Vaccination , 9:00 AM to 9:15 AM, Walk-in
- Lunch , 12:30 PM to 1:15 PM
- Room 2: Follow-up , 2:00 PM to 2:45 PM, Cancelled
Show code Hide code
<x-widget.calendar
label="Appointments"
timezone="America/New_York"
view="day"
:events="$events"
param="day"
:min="now()->subWeek()"
:max="now()->addWeeks(2)"
/>
Multi day
Events over several days are one bar across them, kept in the same row for the week, with an arrow where one carries on from the week before or into the next. All-day events are dates, so no timezone moves them; an end is the moment or day after the event, so one ending at midnight stays on its own day.
Pass
$events
from your controller.
November 2026
Times in United Kingdom Time
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
-
Sunday, November 1, 2026
-
All day
Amara on leave
All day, Thu, Oct 29 to Tue, Nov 3
-
-
Monday, November 2, 2026
-
All day
Amara on leave
All day, Thu, Oct 29 to Tue, Nov 3
-
-
Tuesday, November 3, 2026
-
All day
Amara on leave
All day, Thu, Oct 29 to Tue, Nov 3
-
-
Friday, November 6, 2026
-
All day
Laracon EU
All day, Fri, Nov 6 to Sun, Nov 8
-
-
Saturday, November 7, 2026
-
All day
Laracon EU
All day, Fri, Nov 6 to Sun, Nov 8
-
-
Sunday, November 8, 2026
-
All day
Laracon EU
All day, Fri, Nov 6 to Sun, Nov 8
-
-
Thursday, November 12, 2026
-
All day
Stock-take
All day, Thu, Nov 12 to Thu, Nov 19
-
-
Friday, November 13, 2026
-
All day
Stock-take
All day, Thu, Nov 12 to Thu, Nov 19
-
-
Saturday, November 14, 2026
-
All day
Stock-take
All day, Thu, Nov 12 to Thu, Nov 19
-
From 10:00 PM
Night shift
Sat, Nov 14, 10:00 PM to Sun, Nov 15, 6:00 AM
-
-
Sunday, November 15, 2026
-
All day
Stock-take
All day, Thu, Nov 12 to Thu, Nov 19
-
Until 6:00 AM
Night shift
Sat, Nov 14, 10:00 PM to Sun, Nov 15, 6:00 AM
-
-
Monday, November 16, 2026
-
All day
Stock-take
All day, Thu, Nov 12 to Thu, Nov 19
-
-
Tuesday, November 17, 2026
-
All day
Stock-take
All day, Thu, Nov 12 to Thu, Nov 19
-
From 9:00 PM
Late show
-
-
Wednesday, November 18, 2026
-
All day
Stock-take
All day, Thu, Nov 12 to Thu, Nov 19
-
-
Thursday, November 19, 2026
-
All day
Stock-take
All day, Thu, Nov 12 to Thu, Nov 19
-
-
Tuesday, November 24, 2026
-
All day
Bank holiday
-
Show code Hide code
<x-widget.calendar label="Warehouse calendar" timezone="Europe/London" :events="$events" :nav="false" />
Busy days
A day with more events than per-day shows the first ones and a "more" button, which opens all of them, in full, with their times and status. view="month" keeps the grid where there's no room for titles, as on a phone: each day with events shows how many, on a button that lists them.
Pass
$events
from your controller.
November 2026
Times in United Kingdom Time
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Show code Hide code
<x-widget.calendar label="Appointments" timezone="Europe/London" view="month" :events="$events" :per-day="2" :nav="false" />
Agenda
The month as a list of its days with events, each with its times, status and, for one over several days, its whole span. view="auto", the default, shows this instead of the month where there's no room for it, as on a phone.
Pass
$events
from your controller.
November 2026
Times in New York Time
-
Tuesday, November 3, 2026
-
8:30 AM – 9:15 AM
Dentist
-
-
Saturday, November 7, 2026
-
From 6:40 PM
Flight to Lisbon Booked
Sat, Nov 7, 6:40 PM to Sun, Nov 8, 7:05 AM
-
-
Sunday, November 8, 2026
-
Until 7:05 AM
Flight to Lisbon Booked
Sat, Nov 7, 6:40 PM to Sun, Nov 8, 7:05 AM
-
All day
Lisbon trip
All day, Sun, Nov 8 to Wed, Nov 11
-
-
Monday, November 9, 2026
-
All day
Lisbon trip
All day, Sun, Nov 8 to Wed, Nov 11
-
-
Tuesday, November 10, 2026
-
All day
Lisbon trip
All day, Sun, Nov 8 to Wed, Nov 11
-
-
Wednesday, November 11, 2026
-
All day
Lisbon trip
All day, Sun, Nov 8 to Wed, Nov 11
-
-
Sunday, November 15, 2026
-
9:00 AM
Rent due
-
-
Friday, November 20, 2026
-
7:00 PM – 9:00 PM
Book club Tentative
-
Show code Hide code
<x-widget.calendar label="My plans" timezone="America/New_York" view="agenda" :events="$events" :nav="false" />
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.calendar>
| Prop | Default | Description |
|---|---|---|
| label |
null
|
Required: names the calendar for screen readers ("Team calendar", "Bookings"). |
| id |
null
|
The calendar's id; scripts can reach it by it, so it must be unique. Made up when left out. |
| timezone |
null
|
Required: the timezone timed events are shown in, and named in, e.g. Europe/London or the signed-in person's. All-day events are dates and never move between days, whatever it is. |
| events |
[]
|
The events: arrays, objects or models with id, title, start, and optionally end (exclusive), all_day, href, status (text, e.g. "Tentative") and tone (primary, success, warning, error or neutral). Pass every event that overlaps what's shown, including those that began before it: Bladewell\Support\Calendar::range() gives those moments. |
| keys |
[]
|
Where to read a field when your events name it differently, so models can be passed as they are: the field => its key, a dotted path, or a closure given the event. ['title' => 'room.name', 'start' => 'starts_at', 'end' => 'ends_at', 'href' => fn ($booking) => route('bookings.show', $booking)]. |
| date |
null
|
What's shown: YYYY-MM, YYYY-MM-DD or a date object; the month, week or day holding it. Left out, it's read from the page's query (param), where the links put it, so the links work as they are. Anything that isn't a date, such as a mistyped ?date=, shows today's. |
| view |
'auto'
|
month; week or day, as hours; agenda, a list of the month's days with events; or auto: the month where there's room, the agenda where there isn't, as on a phone. week, too, is a list of its days where there's no room, and month keeps its grid there, each day showing how many events it has, on a button that lists them. |
| views |
null
|
The views people can switch between, on buttons beside Today: any of month, week and day, as an array or "month,week,day". The one showing is read from the page's query (view-param), else it's view, else the first. |
| view-param |
'view'
|
The query parameter the view buttons set. |
| per-day |
3
|
The most events a day shows in the month, or along the top of a week, before the rest go behind its "more" button. |
| nav |
true
|
Shows previous, today and next links. false leaves them out, for a calendar you move yourself. |
| param |
'date'
|
The query parameter the links set: YYYY-MM for a month, YYYY-MM-DD for a week (its first day) or a day. The rest of the page's query is kept. |
| url |
null
|
A link of your own for each period instead, with {date} where the date goes, and with views, {view} where the view goes: "/bookings/{view}/{date}". Use it inside a Livewire component, where the page's own address isn't the request's. |
| reload |
false
|
true: each link loads its whole page, for a page where something besides the calendar changes with the date (a total, a list). Otherwise the script fetches that page and takes only the calendar from it. |
| min |
null
|
The earliest date the links go to: YYYY-MM or YYYY-MM-DD, or a date object. Anything earlier shows this. |
| max |
null
|
The latest date the links go to; a month means its last day. Anything later shows this. |
| week-start |
null
|
The first day of the week, 0 (Sunday) to 6 (Saturday); defaults to the locale's. |
| locale |
null
|
The language for month and day names, dates and times (en, fr, ar…); defaults to the app's. |
| empty |
null
|
Shown in the agenda when there are no events; "No events this month." or "No events this week." by default. |
<x-widget.calendar.entry>
| Prop | Default | Description |
|---|---|---|
| event | Required | One row of a day's list, in the agenda and in a day's "more" list, drawn by the calendar widget. The event. |
| day | Required | The day the row is listed under: an event over several days shows its time on that day. |
| calendar | Required | The calendar's month, which writes the times the locale's way. |
<x-widget.calendar.lanes>
| Prop | Default | Description |
|---|---|---|
| day | Required | One day's bars and dots, drawn by the calendar widget in a month's day and along the top of a week or a day. The day. |
| calendar | Required | The calendar it's part of, which writes the times the locale's way. |
| calendar-id | Required | The calendar's id, for the day's "more" list. |
| compact |
false
|
Where there's no room for bars and titles, as on a phone, shows a count of the day's events instead, on a button that lists them. |
Accessibility
All 6 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.
Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.
Source
What bladewell:add calendar writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds , and the theme and base CSS.
resources/views/components/widget/calendar/entry.blade.php Show
resources/views/components/widget/calendar/index.blade.php Show
resources/views/components/widget/calendar/lanes.blade.php Show
resources/js/widget/calendar/index.js Show
app/View/Widget/Calendar.php Show
app/View/Widget/CalendarBlock.php Show
app/View/Widget/CalendarDay.php Show
app/View/Widget/CalendarEvent.php Show
app/View/Widget/CalendarMonth.php Show
app/View/Widget/CalendarWeek.php Show
app/View/Widget/ElementIds.php Show
app/View/Widget/LocalDate.php Show