WebProCMS ships with a built-in events system: events with a scheduling-grade date (start/end, all-day, time zone), recurring series, venue address, cost, website link, featured image, categories, and comments — surfaced on a public events listing (a searchable Upcoming/Past list with a List / Calendar view toggle) at /events plus per-event detail pages. Events are an ordinary content type, managed at Dashboard → Events, so they share the media library, the design library, taxonomies, scheduling, search, and SEO with everything else in the CMS.
Pricing. The Date & Time field type (and its facet + JSON-LD) is free for any content type — every install can give a type a deadline, webinar time, or booking window. The Events product — the pre-built Events content type plus the calendar and event-detail layouts — is a membership feature (
events, default OFF), toggled at Dashboard → Settings → Features. So the platform date capability is core; the curated Events experience is the paid unlock.
The problem
An events calendar is the textbook case of a feature most sites don't need and the ones that do don't want to re-learn. Bolting it on as its own subsystem — its own table, its own media uploader, its own category model, its own design conventions — means editors learn one media flow for blog posts and a different one for events, and the two diverge over time. But modelling events as a plain custom content type loses everything that makes an events calendar an events calendar: a real date (not a naive string), upcoming/past filtering, a month-grid view, and repeating series.
The trick is to make events feel native — opinionated about the date shape, pre-wired to a calendar and to schema.org Event — while still being just a content type that composes with the media library, taxonomies, comments, relations, and the design library like every other type.
The fix
Events is a seeded content type whose fields reproduce the event shape using reusable capabilities that any content type can use — none of them are events-specific:
| Field | Type | Capability it uses |
|---|---|---|
| Title | text |
auto-derived item title |
| Subtitle | text |
— |
| Event Date | datetime |
the scheduling-grade date field — range, per-entry recurrence, time zone, and an Upcoming/Past index facet |
| Venue | text |
picks from the saved venues — choosing one fills Venue Address |
| Venue Address | address |
the international address field → directions link + schema.org location |
| Cost | text |
— |
| Website URL | text |
— |
| Details | richtext_tiptap |
the body; auto-appends content blocks (CTA / gallery / FAQ) |
| Category | taxonomy |
real categories with pretty archive URLs |
Type-level config declares schema_type = Event, excerpt + comments on, and the calendar (Calendar / List toggle) as the default list layout with the event-detail show layout.
The datetime field — the one genuine moat
The thing an events calendar needs that plain content types don't have is a real date. The datetime field type stores { start, end?, all_day?, tz?, repeat? } and is reusable by any type (a job-opening's closing date, a webinar time, a promotion window) — and it's free on every install. It drives four things:
- Display —
{item.data.event_date}renders a human, range-aware string ("May 1, 2030, 7:00 PM – 10:30 PM", all-day, or a multi-day span). An event entered in a time zone other than the site's names it after the time ("7:00 PM EDT"), here and on the calendar layouts. - The Upcoming / Past facet — a flag on the field turns it into a relative date facet on the listing page, instead of filtering by a stored value. An event stays Upcoming until it is over: a timed one until its end (else its start), so an event in progress is still listed; a date-only or all-day one through its last day. Both are judged on the site's clock, and the calendar's Upcoming / Past list uses the same rule. An event entered in its own time zone is judged — and sorted — by the moment it really happens: on a Central site a 7 PM Pacific event stays Upcoming until 9 PM Central and sorts after an 8 PM Central one. Saving such an event stores its start and end on the site's clock beside it (
site_start/site_end, derived — never typed — and absent from every event without a zone of its own), which is what the listings' SQL compares; changing the site's time zone re-derives them. A repeating series stays Upcoming while any of its dates is still to come, and its card shows — and sorts by — its next date. Guest pages are response-cached, so thepages:sync-time-boundariessweep (LazyCron, every 5 minutes) clears the cache when a published event starts or ends and at the site's midnight; a finished event, or the calendar's "today", doesn't linger on a cached page (see conditional-rendering.md). - The calendar/list layouts — two
@requiresFeature eventspage-design layouts a datetime-having type can choose between from the layout picker:content-calendar-month(List view — opens to the searchable Upcoming/Past list) andcontent-calendar-grid(Calendar view — opens to the month grid). Both carry the same Calendar / List toggle, title search, day view, and Upcoming/Past tabs; they differ only in which view loads first and which toggle button sits on the left. These layouts are the membership-gated piece. - schema.org
Event—startDate/endDateauto-map to the field's start/end andlocationto the venue address, so the generated show page emitsEventJSON-LD with zero events-specific code. A timed date carries its offset (the event's own zone, else the site's); a date-only or all-day one is a bare date; an end before the start is left out.
Saved venues
An events calendar reuses the same handful of places, so the Venue field picks from a list of saved venues. Type in the Venue field and the saved venues matching the name (or the address) drop down; pick one and its full address fills Venue Address, map pin included. An editor can do one of three things:
- Pick a saved venue. The name and address fill in. A status line under the field reads Saved venue whenever the typed name matches one.
- Add a new one. Type a name that isn't saved and fill in the address. The field reads One-off venue · Save as a venue, and clicking Save as a venue stores the name with the address currently on screen, so it's in the list next time.
- Type a one-off. Leave it unsaved. The event keeps what was typed and the saved list doesn't change.
Picking copies the venue into the event: the event keeps its own venue_name and venue values, which is why the detail layout, the calendar feed, the add-to-calendar links and the Event JSON-LD needed no change. It also means editing or deleting a saved venue never changes an event already saved. Saved venues are managed on the type's Venues tab (Dashboard → Events → Venues): add, edit, search, delete. The field's Manage venues link opens that tab in a new browser tab so a half-filled event isn't lost, and the picker re-fetches the list each time the field gains focus, so a venue added there shows up without a reload. Names are unique ignoring case and extra spaces, and a second Save as a venue with the same name returns the saved one rather than making a duplicate or overwriting its address.
The capability isn't events-specific. It is an option on any text field: Content Types → edit → field → Saved venues names one of the type's address fields to fill (stored as saved_venues_address). Every type that uses it shares one venue list, and the Venues tab appears on each of them. The seeded Events type ships with it on; the enable_saved_venues_on_event_venue_fields migration turned it on for Events types created before it existed, matched on the venue_name + venue field pair (so a renamed type is covered), without overriding a choice already made. A field's key follows its label, so relabelling Venue Address re-keys it. On the next save of the type, the option moves to the renamed field when the type has exactly one address field, and is cleared (the picker turns off) when there are none or several to choose from.
Recurrence — owned occurrences
A datetime field can opt into a repeat rule with RRULE-grade options: daily / weekly / monthly / yearly frequency, an interval ("every 2 weeks"), an until date, end after N occurrences (COUNT), multiple weekdays per week ("every Mon + Wed"), and monthly same-day-of-month or same-nth-weekday anchoring ("the 2nd Tuesday"; an anchor on its month's last such weekday tracks "the last Friday" through 4- and 5-week months). On save, RepeatingItemChildrenJob expands the rule into occurrence items owned by the parent (owner_id), reusing the existing owned-items infrastructure wholesale — occurrences are already excluded from lists and search, already cascade-deleted with the parent, and an occurrence's detail URL redirects to its parent. The only new code is generic date arithmetic, so recurring office-hours / classes / sessions get the same machinery. The rule is stepped on the event's own wall clock: every date keeps the start's clock time (one anchored at 2:30 AM on a spring-forward day too), the until date is a date on that clock, and each occurrence keeps the series' start→end span as the clock reads it — a 10 PM – 6 AM lock-in still ends at 6 AM on the night the clocks change. The month calendar surfaces every occurrence on its date. A series materializes up to 365 occurrences around today (in the event's time zone, else the site's) rather than counted from its first date: the dates still to come plus up to 90 of the most recent past ones. A series begun recently keeps every date it has had, and one ending soon back-fills with more of its past — so a long-running series saved today still shows its coming dates and its recent history. Since that window is fixed at save time, the nightly content:top-up-occurrences sweep (LazyCron, daily from 3 AM site time) rebuilds a series once its materialized dates end within 90 days while its rule has more to come, so a series nobody edits never runs out; a healthy series, or one whose rule has ended, is left alone. Rebuilds of one series never overlap: the save hook and the sweep share a per-series lock. --dry-run lists the series running low without rebuilding them. Occurrences are copies rebuilt on every save, so they never write activity-log entries or fire content_item.* webhooks of their own.
Public listing and detail
The generated content-type pages register the usual routes — GET /events (events.index), GET /events/{record} (events.show), and GET /events/{taxonomy}/{term} (events.taxonomy) for the category archives. The index opens on the chronological Upcoming / Past list (with a title search), which a visitor flips to a month-grid calendar via a ?view= / ?tab= toggle (server-rendered, no Livewire state). The detail page is the events-scoped event-detail design-library row (hero image, a date / venue+directions / cost info card, the body with auto-appended content blocks, and a back link). Editors swap either layout from the dashboard like any content type.
Subscribable calendar feed + add-to-calendar
Every datetime-carrying type gets an iCalendar feed at /calendar/{type}.ics (content.calendar-feed) — the calendar layouts show a Subscribe button that hands the webcal:// form to Google/Apple/Outlook, so new events appear in subscribers' calendars automatically. Recurring events are expanded into individual VEVENTs (occurrence children), so no client-side RRULE support is needed; times are written in UTC from the event's own time zone, else the site's; all-day events use proper VALUE=DATE semantics, and venue name + address flatten into LOCATION. Each event's detail page also gets a per-event Add to calendar link (/calendar/{type}/{slug}.ics). Types without a datetime field 404.
Alongside the .ics download, every datetime-carrying type also exposes two computed add-to-calendar template link tokens — {item.calendar_google_url} (Google Calendar's action=TEMPLATE render URL) and {item.calendar_outlook_url} (Outlook web's compose URL) — prefilled with the event's title, UTC start/end (all-day events use bare dates with an exclusive end, matching the ICS semantics), venue name + address as the location, and the subtitle + detail-page URL as the description. The event-detail layout renders all three side by side under the Date & Time card (Add to calendar / Google Calendar / Outlook); the tokens are also pickable in the design-library editor for any custom row. Items without a usable start resolve the tokens to nothing.
Free RSVP (no ticketing required)
The Event RSVP block (built into event-detail, also available as a standalone row) collects name, email, and party size — no payment, no capacity math, the tier below Event Ticketing. Recurring events get an occurrence picker so each date keeps its own headcount. Attendees get a confirmation email with the event's calendar invite attached and a tokenized cancel link; repeat submissions with the same email are idempotent (they update the party size instead of duplicating). Responses live under Dashboard → Event RSVPs (dashboard.events.rsvps, manager+) with an event filter, cancelled toggle, headcount total, and CSV export. When an event has paid tickets on sale, the detail layout shows the ticket picker instead of the RSVP form.
Member-submitted events with moderation
The Submit an Event row (design library → Events category) lets signed-in members (Memberships feature) propose events: title, start/end, all-day flag, venue, cost, and a description. Each submission is created as a draft events item — publishing it from the content dashboard is the moderation step, so nothing appears publicly unapproved. The submitter's member id/name/email ride along in the item's data (_submission), the business email gets a review notification, and submissions are rate-limited per member. Guests see a member sign-in prompt instead of the form.
Native, shared infrastructure
Because events are content items, they get — for free, with the same UX as the blog — the media library (featured image + galleries, one alt-text/Replace flow), Scout search, scheduled publishing (scheduled status + the publish-due cron), previous_slugs redirects, translations, the SEO meta editor, the per-record detail-layout picker, the dashboard column picker, custom per-item pages, relations to other content types, comments, and the design-library data-source preset (content_type:events) that binds any row to events.
What lives where
| Path | Purpose |
|---|---|
app/Jobs/SeedDemoDataJob.php |
demoContentTypes() defines the events type (fields + schema_type + the calendar/event-detail default-layout keys); seedContentItems() seeds demo events (incl. a weekly-recurring one) + photos. |
app/Support/DateValue.php |
The datetime value object — normalize, format, recurrence expansion (occurrences()). |
app/Jobs/RepeatingItemChildrenJob.php |
Materializes a repeating datetime field's occurrences as owned child items. |
app/Support/CalendarGrid.php |
Builds the 6×7 month grid (forType) and the Upcoming/Past list (listForType), including owned occurrence-children. |
resources/design-library/rows/page-layouts/list/content-calendar-month.blade.php |
List view — the calendar+list layout that opens to the list (the events default). @requiresFeature events. |
resources/design-library/rows/page-layouts/list/content-calendar-grid.blade.php |
Calendar view — the same layout that opens to the month grid instead. @requiresFeature events. |
resources/design-library/rows/page-layouts/detail/event-detail.blade.php |
The events-scoped detail layout (@requiresContentType events, @requiresFeature events). |
app/Support/StructuredData.php |
schema.org emitter — maps Event startDate/endDate to a datetime field and location to the address field. |
app/Support/ContentIcsBuilder.php + app/Http/Controllers/ContentCalendarFeedController.php |
The subscribable /calendar/{type}.ics feed + per-event .ics download (routes in routes/cms.php — committed core, since routes/web.php is per-install). |
app/Support/EventRsvps.php + app/Models/EventRsvp.php + app/Livewire/EventRsvp.php |
The free-RSVP flow: idempotent create, confirmation email w/ invite, tokenized cancel (events.rsvp.cancel), event_rsvps table, dashboard list at dashboard.events.rsvps. |
app/Livewire/EventSubmit.php + resources/design-library/rows/events/event-submit.blade.php |
Member event submissions → draft items + owner notification. |
resources/design-library/rows/page-layouts/detail/event-rsvp.blade.php |
Standalone RSVP row for detail layouts that predate the built-in block. |
app/Support/DataSources/Presets/ContentTypePreset.php |
The per-type data-source preset; the datetime facet, address/directions tokens, the {item.calendar_google_url}/{item.calendar_outlook_url} add-to-calendar template links, and {item.auto.date}/{item.data.X} date formatting live here. |
app/Support/SavedVenues.php + app/Models/SavedVenue.php |
Saved venues: the saved_venues_address field option (resolve, normalize on type save), the picker list, case-insensitive de-duplication. Table saved_venues (venues belongs to Venue Rental). |
app/Concerns/HandlesSavedVenues.php + the venuePicker Alpine component in resources/js/manager.js |
The item-form picker: useSavedVenue (fill name + address) and saveVenueFromField (Save as a venue), both resolved against the type's stored schema. Rendered by partials/dynamic-fields.blade.php. |
resources/views/pages/dashboard/content/⚡venues.blade.php |
The Venues tab (dashboard.content.venues, content-scope like Items; 404 for a type without a saved-venue field). |
app/Support/Features.php + DesignRow::scopeAvailableFeature() |
The events feature flag + the per-row @requiresFeature gate that hides the calendar/event-detail layouts and the seeded type unless the feature is on. |
For how content types work in general, see custom-content-types.md, content-type-relations.md, and blog.md (the other built-in record system folded into the content-type model).
Settings reference
The Events product is gated by the events membership feature; once it's on, events are configured like any content type.
| Key / Where | Notes |
|---|---|
features.events.enabled |
Master toggle for the Events product (admin-only, Dashboard → Settings → Features, default OFF). Gates the seeded Events type and the calendar + event-detail layouts. The datetime field type is unaffected — it's always available. |
| Dashboard → Events | Create/edit events; pick a per-record detail layout; manage categories. |
| Dashboard → Events → Venues | The saved venues the Venue field picks from — add, edit, delete. Shared by every type with a saved-venue field. |
| Dashboard → Content Types → Events | Edit the field schema, the default list/detail layouts, the schema.org mapping, and the comments/excerpt toggles. |
index_layout.default.events |
The default list layout (content-calendar-month, the List view variant, out of the box). Swap to the Calendar view (content-calendar-grid) — or any other list layout — from the layout picker. |
detail_layout.default.events |
The default detail layout (event-detail out of the box). |