Statamic Calendar
Recurring events and cached occurrences for Statamic. Works with Statamic 5 and 6.
Features
- Define complex recurrence patterns using RFC 5545 (RRULE) via
rlanvin/php-rrule - Materialize occurrences into Laravel cache for fast listings
- Antlers tags for listing, current occurrence, next occurrences, and month grid
- Month calendar view — server-rendered, navigable via query params, no JS required
- JSON REST API for JS-based calendar components (opt-in)
- iCalendar (.ics) feed for calendar app subscriptions + per-event "Add to calendar" downloads
- Cache-build event for adding custom occurrence fields to tag/API output
- Pagination for REST API responses and Antlers occurrence lists
- Two URL strategies: query string (default, Statamic-native) or date segments
- Control Panel Visit URL and Live Preview support for event entries
- Example templates for index (list, archive, calendar grid) and show pages
Requirements
- PHP 8.3+
- Statamic 5 or 6
- Laravel 11+
Installation
composer require el-schneider/statamic-calendar
Publish the config:
php artisan vendor:publish --tag=statamic-calendar
Blueprint Setup
The addon expects a dates grid field on your event entries. You can publish an example blueprint to get started:
php artisan vendor:publish --tag=statamic-calendar-examples
This publishes to resources/vendor/statamic-calendar/examples/. Copy the blueprint to your collection:
cp resources/vendor/statamic-calendar/examples/blueprints/collections/events/event.yaml \
resources/blueprints/collections/events/event.yaml
Or add the dates grid field to your existing blueprint manually. See the example blueprint for the expected sub-field handles (start_date, start_time, end_date, end_time, is_all_day, is_recurring, frequency, etc.).
Build the Cache
After adding events, rebuild the occurrence cache:
php artisan occurrences:rebuild
The cache rebuilds automatically when entries are saved or deleted in the Control Panel.
URL Strategies
The addon supports two strategies for occurrence URLs. Configure in config/statamic-calendar.php:
Query String (default)
Uses Statamic's native collection routing. The addon doesn't register any routes — your collection config handles everything:
# content/collections/events.yaml
route: '/events/{slug}'
Occurrence URLs look like /events/my-event?date=2025-03-15.
Date Segments (opt-in)
For SEO-friendly date-based URLs like /calendar/2025/03/15/my-event. Enable in config:
'url' => [
'strategy' => 'date_segments',
'date_segments' => [
'prefix' => 'calendar',
],
],
The addon registers a route at /{prefix}/{year}/{month}/{day}/{slug}.
Control Panel URLs and Live Preview
Saved entries in the configured events collection automatically get Visit URL and Live Preview actions in the Control Panel. No collection route is needed when using date_segments.
The action URL uses:
- the next occurrence;
- or the most recent occurrence when the event has no upcoming dates.
Entries without a valid occurrence show neither action. Live Preview resolves the occurrence from the unsaved form values, so changes to the title and dates are rendered before saving.
For query_string, keep the native collection route shown above because the occurrence URL is built on top of the entry URL. For date_segments, leave the collection route unset unless the collection needs one for another reason.
Existing Custom Entry Classes
The addon does not replace an existing custom Statamic Entry class. Add the calendar URL concern to that class instead:
use ElSchneider\StatamicCalendar\Entries\Concerns\HasCalendarUrls;
class Entry extends \Statamic\Entries\Entry
{
use HasCalendarUrls;
}
When using the Eloquent Driver, extend Statamic\Eloquent\Entries\Entry instead. Existing custom Entry-class registration remains unchanged.
iCalendar (.ics) Export
The addon exposes an .ics feed that calendar apps (Apple Calendar, Google Calendar, Outlook) can subscribe to. Enabled by default.
Subscribable Feed
GET /calendar.ics — returns all cached occurrences as a standard iCalendar feed. Calendar apps can subscribe to this URL and will receive updates as events change.
Single-Event Download
GET /calendar.ics/{occurrenceId} — returns a single occurrence as a downloadable .ics file. Use this for "Add to calendar" buttons. The response includes a Content-Disposition: attachment header.
Configuration
// config/statamic-calendar.php
'ics' => [
'enabled' => true, // set to false to disable .ics routes
'feed_url' => '/calendar.ics',
'calendar_name' => env('APP_NAME', 'Calendar'),
],
Template Usage
{{-- Subscribe link --}}
<a href="{{ calendar:ics_url }}">Subscribe to calendar</a>
{{-- Per-event download inside a calendar loop --}}
{{ calendar from="now" limit="10" }}
<a href="{{ calendar:ics_download_url }}">
Add to calendar
</a>
{{ /calendar }}
REST API
An opt-in JSON API for occurrences, designed for JS-based calendar components (FullCalendar, Toast UI Calendar, custom Alpine/Vue/React widgets, etc.) that build their view client-side.
Enable
Set the env var or publish the config:
STATAMIC_CALENDAR_API_ENABLED=true
// config/statamic-calendar.php
'api' => [
'enabled' => env('STATAMIC_CALENDAR_API_ENABLED', false),
'route' => env('STATAMIC_CALENDAR_API_ROUTE', 'api/calendar/occurrences'),
'middleware' => env('STATAMIC_CALENDAR_API_MIDDLEWARE', 'api'),
'max_per_page' => 100,
],
Endpoint
GET /api/calendar/occurrences
| Parameter | Type | Description | Default |
|---|---|---|---|
from |
date |
Start date (ISO 8601 or Y-m-d) |
now |
to |
date |
End date | — |
limit |
int |
Max occurrences (ignored when paginating) | — |
page |
int |
Page number; enables pagination | — |
per_page |
int |
Items per page; also enables pagination | 15 |
sort |
string |
asc or desc
|
asc |
tags |
string |
Comma-separated tag slugs | — |
organizer |
string |
Organizer entry ID | — |
Response
{
"data": [
{
"id": "abc-123-2026-03-06-150000",
"entry_id": "abc-123",
"title": "Laracon Online",
"slug": "laracon-online",
"teaser": "The best Laravel conference",
"organizer_id": "org-456",
"organizer_slug": "laravel-org",
"organizer_title": "Laravel",
"organizer_url": "/organizers/laravel-org",
"tags": ["tech", "laravel"],
"start": "2026-03-06T15:00:00+00:00",
"end": "2026-03-06T16:00:00+00:00",
"is_all_day": false,
"is_recurring": true,
"recurrence_description": "every week on Friday",
"url": "/events/laracon-online?date=2026-03-06"
}
]
}
Examples
// Month view
fetch('/api/calendar/occurrences?from=2026-03-01&to=2026-03-31')
// Week view
fetch('/api/calendar/occurrences?from=2026-03-02&to=2026-03-08')
// Next 5 upcoming
fetch('/api/calendar/occurrences?limit=5')
// Paginated archive
fetch('/api/calendar/occurrences?from=2026-01-01&page=2&per_page=25')
// Filtered by tag and organizer
fetch('/api/calendar/occurrences?tags=music,art&organizer=org-123')
CORS
The API uses Laravel's api middleware group, so cross-origin requests are handled by your app's config/cors.php. Laravel's default config already allows api/* paths from all origins — adjust as needed.
Custom Occurrence Fields
Need images, categories, or occurrence-specific flags in API/tag output? Listen to ElSchneider\StatamicCalendar\Events\OccurrenceBuilding. It runs once per materialized occurrence during cache rebuild, with access to both the source entry and resolved occurrence. See config/statamic-calendar.php and the event class docblock for the full recipe.
Setting Up Templates
Events Index
Create a page or route that uses the {{ calendar }} tag. For example, add to routes/web.php:
Route::statamic('events', 'events/index', [
'title' => 'Upcoming Events',
]);
Then create resources/views/events/index.antlers.html:
<h1>Upcoming Events</h1>
{{ calendar from="now" limit="20" }}
<a href="{{ url }}">
<h2>{{ title }}</h2>
<p>{{ start format="l, M j, Y" }}</p>
{{ if is_recurring }}
<p>{{ recurrence_description }}</p>
{{ /if }}
</a>
{{ /calendar }}
Event Show Page
Set the collection template to events/show, then create resources/views/events/show.antlers.html:
<h1>{{ title }}</h1>
{{ calendar:current_occurrence }}
<p>{{ start format="l, F j, Y" }}</p>
{{ if !is_all_day }}
<p>
{{ start format="g:i A" }}
{{ if end }}– {{ end format="g:i A" }}{{ /if }}
</p>
{{ else }}
<p>All day</p>
{{ /if }}
{{ if is_recurring }}
<p>Repeats: {{ recurrence_description }}</p>
{{ /if }}
{{ /calendar:current_occurrence }}
{{ calendar:next_occurrences :entry="id" limit="5" }}
<a href="{{ url }}">{{ start format="M j, Y" }}</a>
{{ /calendar:next_occurrences }}
The {{ calendar:current_occurrence }} tag reads the ?date= query parameter and resolves the matching occurrence for the current entry. When using the date_segments strategy, the date is extracted from the URL instead.
With the date_segments strategy, the occurrence controller also exposes the current occurrence directly to the show template: start, end, is_all_day, is_recurring, recurrence_description, occurrence_url, and occurrence_canonical_url. Compose SEO markup from those values plus your own blueprint fields:
{{ push:head }}
<link rel="canonical" href="{{ occurrence_canonical_url }}">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Event",
"name": {{ title | to_json }},
"startDate": "{{ start format='c' }}",
{{ if end }}"endDate": "{{ end format='c' }}",{{ /if }}
"url": "{{ occurrence_canonical_url }}"
}
</script>
{{ /push:head }}
Antlers Tags
{{ calendar }}
Lists occurrences from the cache (or resolves them live for non-default collections).
| Parameter | Description | Default |
|---|---|---|
from |
Start date | now |
to |
End date | — |
limit |
Max occurrences (ignored when paginating) | — |
paginate |
Items per page | — |
page_name |
Query string page key | page |
as |
Results variable name when using pagination or grouped output | occurrences |
collection |
Collection handle | config value |
tags |
Filter by taxonomy terms | — |
{{ calendar:month }}
Renders a month grid with weeks, days, and occurrences. Navigation via query params, fully server-rendered. See the example index template for usage.
| Parameter | Description | Default |
|---|---|---|
param |
Query string parameter name (allows multiple calendars per page) | month |
week_starts_on |
Day of week (0 = Sunday, 1 = Monday) |
1 |
fixed_rows |
Always render 6 rows for consistent grid height | false |
collection |
Collection handle | config value |
tags |
Filter by taxonomy terms | — |
Variables available inside the tag pair: month_label, year, month, prev_url, next_url, today, day_labels (loop with label, full_label), and weeks → days → date, day, is_current_month, is_today, occurrences.
{{ calendar:current_occurrence }}
Resolves the current occurrence for the entry in context, based on the ?date= query param. Use as a tag pair — variables available inside:
-
start— Carbon date -
end— Carbon date (nullable) -
is_all_day— boolean -
is_recurring— boolean -
recurrence_description— human-readable recurrence rule -
occurrence_url— the occurrence URL
{{ calendar:next_occurrences }}
Lists upcoming occurrences for a specific entry.
| Parameter | Description | Default |
|---|---|---|
entry |
Entry ID | current context id
|
from |
Start date | now |
to |
End date | — |
limit |
Max occurrences | 5 |
{{ calendar:ics_url }}
Returns the URL to the .ics calendar feed. Use it to offer a "Subscribe" link:
<a href="{{ calendar:ics_url }}">Subscribe to calendar</a>
{{ calendar:ics_download_url }}
Returns the .ics download URL for a single occurrence. Use inside any {{ calendar }} loop to offer an "Add to calendar" button:
{{ calendar from="now" limit="10" }}
<h2>{{ title }}</h2>
<a href="{{ calendar:ics_download_url }}">Add to calendar</a>
{{ /calendar }}
| Parameter | Description | Default |
|---|---|---|
occurrence_id |
Occurrence ID ({entry_id}-{Y-m-d-His}) |
context occurrence_id, else id
|
{{ calendar:for_organizer }}
Lists upcoming occurrences for an organizer (from cache).
| Parameter | Description | Default |
|---|---|---|
organizer |
Organizer entry ID | current context id
|
limit |
Max occurrences (ignored when paginating) | 5 |
paginate |
Items per page | — |
page_name |
Query string page key | page |
as |
Results variable name when using pagination or grouped output | occurrences |
Configuration
Key options in config/statamic-calendar.php:
| Key | Description | Default |
|---|---|---|
collection |
Source collection handle | events |
fields.dates |
Grid field handle | dates |
url.strategy |
query_string or date_segments
|
query_string |
url.query_string.param |
Query parameter name | date |
url.date_segments.prefix |
URL prefix for date segments | calendar |
api.enabled |
Enable JSON REST API | false |
api.route |
API route path | api/calendar/occurrences |
api.middleware |
Middleware group | api |
api.max_per_page |
Max API pagination page size | 100 |
ics.enabled |
Enable .ics feed routes | true |
ics.feed_url |
Feed URL path | /calendar.ics |
ics.calendar_name |
Calendar name in .ics output | APP_NAME |
cache.key |
Cache store key | statamic_calendar.occurrences |
cache.days_ahead |
Recurrence expansion window | 365 |
Cache
Occurrences are materialized into Laravel's cache for fast listing. The cache rebuilds automatically when entries are saved or deleted.
Manual rebuild:
php artisan occurrences:rebuild
Example Blueprint
Publish the example blueprint:
php artisan vendor:publish --tag=statamic-calendar-examples
Then copy it into your collection's blueprints directory (see Blueprint Setup above).
Testing
composer install
vendor/bin/pest
Contributing
Contributions are welcome! Please open an issue first to discuss what you'd like to change.
License
MIT — see LICENSE.md for details.