NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Events for Laravel

Calendar files, feeds and JSON-LD

Calendar files

return Events::for($event)->ics($request->user());          // one event; the join link only if this viewer may see it
return Events::ics($events, $request->user());              // a list: events the viewer may not see are skipped
return Events::feeds()->public(new EventFeedQuery(category: 'jazz'));
return $user->eventFeed();                                  // = Events::feeds()->for($user)

// In a Mailable's attachments(): Attachment::fromData(fn () => $ics, 'event.ics')->withMime('text/calendar')
$ics = (string) Events::for($event)->ics($user);

Every entry point returns Calendar\IcsCalendar — Responsable (text/calendar; charset=utf-8, a sanitised filename, attachment for an event and inline for a feed) and Stringable. The filename is the slug of a public event (else its uuid) + .ics, events.ics for a list or the public feed and my-events.ics for a personal feed. A viewer who may not see an event gets nothing: the list skips it, and a single event’s ics($viewer) throws the uniform EventNotFoundException. With no viewer and no by() actor, ics() is trusted PHP: it renders the event (never a draft) but never the join link.

What each event carries

  • UID ({uuid}@{app host}), DTSTAMP, LAST-MODIFIED and SEQUENCE — raised by every status change and every time, venue or link change, and on the unfinished events of a venue whose name, address or coordinates change, so subscribed calendars update.
  • DTSTART and DTEND in UTC — or VALUE=DATE with the exclusive end day for all-day events.
  • SUMMARY, DESCRIPTION (plus the join link for a viewer who may see it), LOCATION (the venue’s name and address, or the join link for an online event the viewer may join), GEO, URL and CATEGORIES (the category and labels).
  • STATUS: CONFIRMED; a postponed event is TENTATIVE, a cancelled one CANCELLED.
  • ORGANIZER;CN=…:mailto:… only when the organizer offers eventOrganizerEmail(). There are never ATTENDEE lines, and drafts are never exported.
  • Text is escaped per RFC 5545, parameters are sanitised and quoted, and lines are folded at 75 octets without splitting a UTF-8 character — titles and descriptions cannot inject properties.

Feeds

use RoundlyConsulting\Events\DataTransferObjects\EventFeedQuery;

Route::get('/calendars/jazz.ics', fn () => Events::feeds()->public(new EventFeedQuery(
    category: 'jazz', label: 'live', organizer: $club, venue: $venue, name: 'Jazz at the club',
)));
  • Events::feeds()->public(EventFeedQuery $query) lists the listed public events that have not ended — scheduled, postponed, rescheduled — plus those cancelled within events.feeds.keep_cancelled_for (P30D), so subscribed calendars receive the cancellation. It filters by category (sub-categories included), label (any of them), organizer and venue, orders by start, caps at events.feeds.max_events and never carries a join link. The calendar is named by the query’s name, else events.feeds.name, and refreshes hourly.
  • Events::feeds()->for($attendee, includePrivate: false) — or $attendee->eventFeed() — lists the announced events an attendee is going to, join links included, that ended at most keep_cancelled_for ago; private ones only on request. It is PHP only: serve it behind a token URL of your own.
  • The API serves the public feed at GET /api/events/feed.ics and one event at GET /api/events/{event}/calendar.ics.

JSON-LD for search engines

@push('head')
    @eventJsonLd($event)      {{-- the script tag, or nothing for unlisted, private and draft events --}}
@endpush
Events::jsonLd($event);           // the array, or null
Events::for($event)->jsonLd();
$event->toJsonLd();
$event->renderJsonLd();           // HtmlString: the script tag, or empty

It is emitted for public events past the Draft phase — unlisted ones with events.seo.json_ld.unlisted, private ones never — in the current locale. The view hex-escapes angle brackets, ampersands and quotes, so a title can never close the script tag. Jazz Night from the README — without its poster, and organized by a team named Blue Note Club — renders:

{
  "@context": "https://schema.org",
  "@type": "Event",
  "name": "Jazz Night",
  "description": "A live quartet, then an open jam session.",
  "startDate": "2026-11-14T20:00:00+01:00",
  "endDate": "2026-11-14T23:00:00+01:00",
  "eventStatus": "https://schema.org/EventScheduled",
  "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
  "location": {
    "@type": "Place",
    "name": "Blue Note",
    "address": {
      "@type": "PostalAddress",
      "streetAddress": "Hlavná 1",
      "addressLocality": "Bratislava",
      "postalCode": "811 01",
      "addressCountry": "SK"
    },
    "geo": { "@type": "GeoCoordinates", "latitude": 48.1439, "longitude": 17.1097 }
  },
  "offers": [
    {
      "@type": "Offer",
      "name": "Free entry",
      "price": "0",
      "priceCurrency": "EUR",
      "availability": "https://schema.org/InStock",
      "url": "https://example.com/en/events/jazz-night"
    },
    {
      "@type": "Offer",
      "name": "Supporter",
      "price": "15.00",
      "priceCurrency": "EUR",
      "availability": "https://schema.org/InStock",
      "url": "https://example.com/en/events/jazz-night"
    }
  ],
  "organizer": { "@type": "Organization", "name": "Blue Note Club" },
  "keywords": "Live, Late night",
  "url": "https://example.com/en/events/jazz-night"
}
  • startDate and endDate carry the event’s own UTC offset — or are date-only for all-day events, with the inclusive last day — plus doorTime.
  • eventStatus: EventScheduled (EventMovedOnline for an announced event that moved), EventPostponed, EventRescheduled with previousStartDate, or EventCancelled with its dates kept; a completed event stays EventScheduled. Seo\EventJsonLd::status($event) gives the status name alone — the API’s schema_status.
  • location is a Place (name, PostalAddress, GeoCoordinates) and/or a VirtualLocation with the event page URL — the join link only with events.seo.json_ld.expose_online_url.
  • image lists the poster’s square, landscape and wide variants; description is plain text of at most 300 characters.
  • offers has one Offer per visible ticket type — price as a decimal string (0 when free), priceCurrency, availability InStock, SoldOut (no seats left, or the sale ended) or PreOrder before the sale opens, validFrom, validThrough and url — or one free offer for an event without ticket types (SoldOut when full without a waitlist).
  • organizer is an Organization or a Person per events.seo.json_ld.organizer_type; keywords are the label names.

URLs

Contracts\EventUrls decides the links in JSON-LD, calendar files and notices — event(), invitation($token) and rsvp($rsvp), each ?string. The default Support\TemplateEventUrls fills the events.urls.* templates with URL-encoded placeholders, and a blank template means no link. Unlisted and private events use events.urls.event_link ({uuid}); when it is blank, urls.event is used only if it has no {slug} — a slug link would 404 for the very people it was sent to. Bind your own class to call route():

use RoundlyConsulting\Events\Contracts\EventUrls;
use RoundlyConsulting\Events\Models\Event;
use RoundlyConsulting\Events\Models\Rsvp;

final class AppEventUrls implements EventUrls
{
    public function event(Event $event): ?string
    {
        return route('meetups.show', $event->uuid);
    }

    public function invitation(string $token): ?string
    {
        return route('meetups.invitation', $token);
    }

    public function rsvp(Rsvp $rsvp): ?string
    {
        return route('meetups.ticket', [$rsvp->event->uuid, $rsvp->getKey()]);
    }
}

// config/events.php — 'urls' => ['resolver' => AppEventUrls::class, …]

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.