On an events website I'd like to add a link to add an event to a vistors calendar. So when they are looking at the details of an event there would be a "Click to add to your calendar" button/link. I already generate/create an ICS file with all the events in it, but this would be just for the one specific event/entry. I'm thinking this would need to create a file on demand.

3 Answers 3


I'm done this using a custom controller action in a module that creates the ICS file on the fly and then sends it back as the response. This way, you can use a library to create the actual file – we're using the excellent spatie/icalendar-generator. This is a bit less error-prone and a bit easier to read, in my opinion. You can just use the public API of the library and don't have to worry about all the idiosyncrasies of the ICS format, since the library takes care of that.

Here's a simplified version of the controller endpoint we use for that purpose:

use Craft;
use craft\elements\Entry;
use craft\web\Controller;
use Spatie\IcalendarGenerator\Components\Calendar;
use Spatie\IcalendarGenerator\Components\Event;
use yii\web\NotFoundHttpException;
use yii\web\Response;

public function actionDownloadIcs(int $event): Response
    $entry = Entry::find()->id($event)->section('events')->one();

    if (!$entry) {
        throw new NotFoundHttpException('The requested event does not exist.');

    $currentSite = Craft::$app->sites->currentSite;
    $calendar = Calendar::create($currentSite->name)

    $event = Event::create()


    return $this->response->sendContentAsFile(
        (string) $calendar,
        sprintf('%s-%s.ics', $entry->start_date->format('Y-m-d'), $entry->slug),
        ['mimeType' => 'text/calendar']

Then you can output a link to that controller action in your templates:

<a href="{{ actionUrl('events/calendar/download-ics', { event: entry.id, }) }}">
    {{ 'Download event as ICS'|t }}

Note that the action path depends on the module ID, the name of the controller and the action.

  • 1
    Interesting alternative approach here! I'm always curious where exactly that tipping point is for reaching for a third-party library. Oct 30 at 16:24
  • @JamesSmith Yeah it could go either way here, but the ICS format just makes my eyes bleed … And for stuff like timezones, recurring event rules etc, the public API of the library is a bit easier to use. And isn't there a line length limit in ICS? Yeah I'm not dealing with that manually ^^
    – MoritzLost
    Oct 30 at 16:42
  • 1
    Yeah the Spatie library is a good way to go as well. RFC 2445 is too annoying to do correctly via Twig imo. Nov 1 at 18:14

No need to create an actual file - you can just use a Twig template as normal and send the appropriate headers to inform the browser what type of response you're sending. You need to be a bit careful with whitespace in the template if I recall correctly, so this code is deliberately not indented. Here's an example that I'm using in production:

{% apply spaceless %}
{% set entry = craft.entries.section('events').slug(craft.app.request.segments|last).one() %}
{% set startDate = entry.eventDate %}
{% header "Content-Type: text/calendar; charset=utf-8" %}
{% header "Content-Disposition: attachment; filename=calendar.ics" %}
PRODID:-//{{ siteName }}//NONSGML v1.0//EN
X-WR-CALNAME:{{ entry.title }}
NAME:{{ entry.title }}
DTSTAMP:{{ now|date("Ymd", "UTC") }}T{{ now|date("Hi", "UTC") }}00Z
UID:{{ entry.id }}@example.com
DTSTART;TZID=Europe/London:{{ startDate|date("Ymd", "UTC") }}T{{ startDate|date("Hi", "UTC") }}00
SUMMARY:{{ entry.title }}
URL:{{ entry.url }}
{% endapply %}
  • I'm not getting any entry data, the file downloads but all the specific entry fields are blank. I'm running Craft 4 if that makes any difference.
    – Paul Frost
    Oct 30 at 17:43
  • Try outputting the file to the screen (i.e., remove the headers) to debug why your entry variable is not working. Be sure to tweak the line that sets the entry variable - I only included it there as an example of how you might want to do it. Oct 31 at 10:48
  • I removed the headers but it still just downloads. The entry variable is correct for me. The section is named events and the date field is eventDate.
    – Paul Frost
    Oct 31 at 15:13
  • If you remove the two header lines it definitely shouldn't trigger a download (assuming your file is named .twig or .html)... which suggests to me maybe you're editing the wrong file or something??! I've tested this now and it outputs correctly for me. Oct 31 at 17:14
  • Iv'e changed the file extension to .twig and it now shows. But still no entry data. in dev mode I get a "Impossible to access an attribute ("title") on a null variable." But without the .ics extension and headers the url in the browser doesn't have the slug for the event entry at the end, so the entry query can't get the slug to retrieve the event data. What is the link url you use to link to the ics file?
    – Paul Frost
    Oct 31 at 21:17

A really easy way to do it would be to use the (free) iCalendar plugin, which handles all of the annoying encoding / decoding for you:https://plugins.craftcms.com/icalendar

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.