liberu-cms/cms-laravel
Liberu CMS for Laravel is a modular content management package for building editable websites and applications. It offers pages, posts, media, menus, SEO tooling, custom content types, and role-based admin features, with clean Laravel integration that helps teams ship CMS-driven sites faster.
This release adds CMS Events Content as a first-class content type in the package family, sitting alongside pages/news. It ships as a content package plus a matching adapter — API, Filament, or Livewire — so you install exactly one delivery layer for the surface you actually need.
Minimal path:
composer require vendor/cms-events vendor/cms-filament # or vendor/cms-api / vendor/cms-livewire
php artisan vendor:publish --tag=cms-events-config
php artisan vendor:publish --tag=cms-events-migrations
php artisan migrate
php artisan vendor:publish --tag=cms-events-seeders # optional demo conference data
composer dump-autoload && php artisan optimize:clear
Pin every package in the family to the same major (^32.0) — the adapters are versioned together and a mismatched driver is the most common install failure.
Where to look first, in order:
config/cms-events.php — model map, route prefixes, timezone source, calendar/feed settings, structured-data toggle.events, event_sessions, speakers, venues, event_speaker pivot, registration_references. Reading these tells you more about the domain than the docs do.src/Models — Event, EventSession, Speaker, Venue.Calendar, EventShow, SpeakerIndex).First real use case: a conference page. Create a venue, attach speakers, create an event, add ordered sessions, point a registration reference at your ticketing provider, then confirm three things — the /events/{slug} page renders, the .ics feed downloads, and the JSON-LD passes Google's Rich Results test. That single loop exercises every part of the new feature set.
Pick one adapter, not all three. The packages are designed to be swappable; installing Filament for the back office and the API for a headless front end is a legitimate and common pairing, but installing Filament and Livewire usually means you're duplicating presentation logic. Editor in Filament, render via Livewire or API — not both.
Model the hierarchy as Event → Session → Speaker/Venue. Sessions are children, not siblings: they carry starts_at/ends_at, an ordering column, and a kind-style discriminator (talk, workshop, break, keynote). Speakers are many-to-many with a pivot role field, and the same speaker record is reused across events — never duplicate a speaker per event, or the speaker archive page fragments.
Override models via the config map, not by editing vendor code. Subclass Event, add your own scopes/accessors (e.g. isSoldOut()), then point config('cms-events.models.event') at it. Same pattern for resource classes and Filament resources; re-registering a subclass into the panel is supported and keeps upgrades painless.
Lean on the scopes, don't hand-roll date filters. The package exposes published/upcoming/past-style scopes. A typical index controller is a scoped, eager-loaded query:
Event::query()
->published()
->upcoming()
->with(['venue', 'speakers', 'sessions' => fn ($q) => $q->orderBy('starts_at')])
->paginate(12);
Archive is a route + scope, not a boolean. Past events move to an archive index on their own route, keeping the primary listing performant and the SEO story clean (canonical list vs. archived detail pages). Wire archive pagination separately so the main listing stays uncached-and-short while the archive can be cached aggressively.
Calendar feeds want their own cache layer. Generate the iCal output once per event (or per filtered listing), cache it keyed on the event's updated_at, and serve it with an ETag. Anything that rebuilds the feed on every request will fall over the first time a bot subscribes to it.
Registration references are a pointer, not a registration. Store provider, external id, optional deep-link URL, and the ticketing state you care about (open, sold-out, closed). Keep them in their own table with a unique constraint on (provider, external_id) and reconcile via a scheduled job or webhook listener — never make the public page depend on a live call to the ticket vendor.
Structured data belongs in one overridable component. Publish the JSON-LD Blade/class component and extend it rather than injecting scripts in layouts. Encode startDate with the venue's offset, nest sessions as subEvent or an eventSchedule, and include offers only when you actually have ticket data.
Test with factories and travel. The archive/upcoming behaviour is entirely date-dependent — $this->travelTo() around the archive boundary is the only reliable way to test it, and it's worth two tests (one second before midnight, one after).
"vendor/cms-events": "^32.0" rather than *.starts_at in UTC, store the venue's IANA timezone, and convert only at render time. Structured data must emit the offset (2025-05-14T09:00:00+02:00), which is not the same string as the raw UTC value — DST boundaries make this visible twice a year and invisible the rest of the time.event_sessions, and check the FK order on a fresh migrate:fresh — it's the cheapest smoke test there is.withoutGlobalScope() when debugging "missing" records.speakers and nested sessions. The API include mechanism makes this easy to trigger silently — always check a listing endpoint with DB::listen() or Telescope before shipping, and eager-load sessions.speakers when the detail view shows session-level speakers.{event:slug}/{session:slug}) rather than relying on global uniqueness.wire:key keyed on date + event id inside the grid, avoid re-rendering the whole month on filter keystrokes (debounce, wire:model.live.debounce.500ms), and prefer a full-page component over an embedded one if you need query-string state. wire:navigate between months should preserve scroll position — test it.published() scope plus a soft-delete check is where this usually goes wrong.php artisan route:list --name=events to confirm which adapter registered what, and php artisan vendor:publish --list-style discovery (or the README's tag table) when a config key seems to have no effect — you may be editing an unpublished default.How can I help you explore Laravel packages today?