wdelfuego/nova-calendar
A Laravel Nova calendar tool that displays your Eloquent models as events on a monthly calendar, with customizable event rendering, filtering, badges, and click-through actions—all configurable from a single Nova resource.
Confirm fit before you write code. This is a Nova Tool, so it is worthless outside Nova and shouldn't influence your admin framework choice. Check composer.json constraints against your Nova major, Laravel, and PHP versions — Nova tools routinely break across majors, and with zero dependents you are the first person on your exact combination. Also read the actual LICENSE file: GitHub reports NOASSERTION, which means nobody has told you what you may do with this, and that's a legal question before it's an engineering one.
Install and register on a branch:
composer require wdelfuego/nova-calendar
php artisan nova-calendar:install # publishes config, possibly model/migration stubs — read the diff
php artisan nova:publish # Nova tool assets; blank calendar panels usually trace back to this
// app/Providers/NovaServiceProvider.php
public function tools(): array
{
return [
new \Wdelfuego\NovaCalendar\NovaCalendar(),
];
}
Where to look first, in this order:
config/nova-calendar.php — the entire integration surface. The eventSources array is what you edit day to day; everything else is set-and-forget.vendor/wdelfuego/nova-calendar/src — specifically the Event class and the event-source base classes (NovaEventSource, CustomEventSource). The fluent event API and the stub you extend are the most accurate documentation this package has.app/. Decide immediately whether you're adopting its schema or copying its shape into your own tables. Default to copying the shape — keep the package a presentation layer so you can delete it in one commit later.resources/views/vendor/nova-calendar only if you need to change rendering. That's the road to a fork; go there last.First use case — exactly one thing. Point the calendar at an existing, trusted, non-sensitive Eloquent model in read-only mode:
namespace App\Nova\Calendar;
use App\Nova\Booking;
use Wdelfuego\NovaCalendar\EventSource\NovaEventSource;
class BookingEventSource extends NovaEventSource
{
// Mirror the stub the installer published — property names shift between releases.
public $model = Booking::class;
public $dateAttributes = ['starts_at', 'ends_at'];
}
// config/nova-calendar.php
'eventSources' => [
\App\Nova\Calendar\BookingEventSource::class,
],
php artisan config:clear
Open the tool, navigate to a month you know has bookings, and verify titles, start/end placement, and click-through. That's an hour-to-a-day spike. Timebox it there — do not let it drift into recurrence, permissions design, or CRUD.
One event source per Nova resource. This is how you get a colour-coded mixed calendar (bookings + shifts + editorial deadlines + support windows) without a polymorphic events table. Each source class is a thin adapter: map attributes, pick a colour, link the resource. If a source grows past ~40 lines, the logic belongs on the model or in a service.
Same model, multiple date attributes. If Booking has both created_at and starts_at and both matter, register two source classes over the same model with different $dateAttributes. Cheaper and clearer than inventing a synthetic view.
Synthetic events for non-rows. Holidays, billing cutoffs, on-call rotations, external deadlines — anything that isn't an Eloquent row — belongs in a CustomEventSource where you build Event objects yourself:
class HolidayEventSource extends CustomEventSource
{
public function nonNovaEvents(): array
{
return collect(config('company.holidays'))
->map(fn ($h) => Event::make($h['label'])
->start(Carbon::parse($h['date'], config('app.timezone')))
->allDay()
->color('#94a3b8'))
->all();
}
}
Authorization is yours, not the package's. The calendar renders every row the query returns. A Nova policy governs the click-through; it does not filter the grid payload. Scope at the source — a global scope on the model, an explicit whereIn('team_id', $visibleTeamIds), or a query method on the source class. This is the one mistake that's an incident rather than a bug. Then, only emit a resource URL when the user can actually view the record:
$url = $user->can('view', $model)
? "/resources/{$model->resourceKey()}/{$model->getKey()}"
: null;
A link that 403s is worse than no link.
Keep name/colour resolution pure. Don't call auth(), DB::, or relationship queries inside the per-event accessors — every call multiplies by the number of events in the rendered window. Use plain attributes, eager loads configured on the source, or a precomputed column. Your model should stay usable outside Nova, and the calendar should stay fast.
Centralise status → colour. One map (config array or a static method on the model) reused by all sources. Hex codes scattered across three adapter classes rot within a sprint.
Time zones: store UTC, convert at the boundary, set allDay() explicitly. DST transitions are the source of nearly every "the calendar is wrong by a day" report. Test a date that falls inside a DST shift.
Recurrence is your expansion, not the package's. It renders events; it does not solve RRULE. Expand occurrences server-side into concrete rows for the visible window, cap the horizon (e.g. 12 months), cache the expansion. Never recurse unbounded inside an accessor.
Wrap the page defensively. Consider a thin sub-class of the tool or a small middleware so an exception in one event source yields an empty calendar plus a logged error, rather than a 500 that takes down the Nova shell mid-demo.
Test the provider, not the UI. Assert on your event source's output for a fixed window against a seeded dataset — "March returns 14 events, one spanning 3 days, two all-day" — rather than driving the Vue layer through a browser. Faster, stable, and it documents the date semantics you settled on.
config:clear after every eventSources edit. This is overwhelmingly the #1 cause of "my new source doesn't appear." Bake it into your muscle memory.nova:publish after every Nova or package upgrade. Stale compiled assets are the #1 cause of a blank panel. Do it in the deploy script, not manually.tools()? source listed in config? config cache cleared? source actually returns rows for the rendered range? attribute names match DB columns exactly? timezone pushing events outside the window? Temporarily log the returned event count inside the source instead of guessing.starts_at/ends_at, filter by visible range inside the source, and watch long-window agenda views — payload size and render time blow up before CPU does.config:clear — no data migration.app.locale if your audience isn't English; otherwise month and weekday names will surprise people.How can I help you explore Laravel packages today?