<p>I'm done this using a <a href="https://craftcms.com/docs/4.x/extend/controllers.html" rel="nofollow noreferrer">custom controller action</a> 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 <a href="https://github.com/spatie/icalendar-generator" rel="nofollow noreferrer"><code>spatie/icalendar-generator</code></a>. 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.</p>
<p>Here's a simplified version of the controller endpoint we use for that purpose:</p>
<pre class="lang-php prettyprint-override"><code>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()-&gt;id($event)-&gt;section('events')-&gt;one();

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

    $currentSite = Craft::$app-&gt;sites-&gt;currentSite;
    $calendar = Calendar::create($currentSite-&gt;name)
        -&gt;productIdentifier($currentSite-&gt;getBaseUrl());

    $event = Event::create()
        -&gt;name($entry-&gt;title)
        -&gt;startsAt($entry-&gt;start_date)
        -&gt;endsAt($entry-&gt;end_date)
        -&gt;description($entry-&gt;description)
        -&gt;address($entry-&gt;address)
        -&gt;url($entry-&gt;getUrl());

    $calendar-&gt;event($event);

    return $this-&gt;response-&gt;sendContentAsFile(
        (string) $calendar,
        sprintf('%s-%s.ics', $entry-&gt;start_date-&gt;format('Y-m-d'), $entry-&gt;slug),
        ['mimeType' =&gt; 'text/calendar']
    );
}
</code></pre>
<p>Then you can output a link to that controller action in your templates:</p>
<pre class="lang-twig prettyprint-override"><code>&lt;a href=&quot;{{ actionUrl('events/calendar/download-ics', { event: entry.id, }) }}&quot;&gt;
    {{ 'Download event as ICS'|t }}
&lt;/a&gt;
</code></pre>
<p>Note that the <a href="https://craftcms.com/docs/4.x/extend/controllers.html#action-paths" rel="nofollow noreferrer">action path</a> depends on the module ID, the name of the controller and the action.</p>

CC BY-SA 4.0
2023-10-30T16:24:10.983Z
Interesting alternative approach here! I'm always curious where exactly that tipping point is for reaching for a third-party library.
James Smith
CC BY-SA 4.0
2023-10-30T16:42:43.690Z
@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
CC BY-SA 4.0
2023-11-01T18:14:23.697Z
Yeah the Spatie library is a good way to go as well. RFC 2445 is too annoying to do correctly via Twig imo.
andrew.welch
CC BY-SA 4.0
2023-10-30T11:36:30.973Z
2023-10-30T11:36:30.973Z
MoritzLost
<p>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:<a href="https://plugins.craftcms.com/icalendar" rel="nofollow noreferrer">https://plugins.craftcms.com/icalendar</a></p>

CC BY-SA 4.0
2023-10-31T23:14:21.257Z
2023-10-31T23:14:21.257Z
andrew.welch
<p>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:</p>
<pre class="lang-twig prettyprint-override"><code>{% apply spaceless %}
{% set entry = craft.entries.section('events').slug(craft.app.request.segments|last).one() %}
{% set startDate = entry.eventDate %}
{% header &quot;Content-Type: text/calendar; charset=utf-8&quot; %}
{% header &quot;Content-Disposition: attachment; filename=calendar.ics&quot; %}
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//{{ siteName }}//NONSGML v1.0//EN
X-WR-CALNAME:{{ entry.title }}
NAME:{{ entry.title }}
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/London
TZURL:http://tzurl.org/zoneinfo-outlook/Europe/London
X-LIC-LOCATION:Europe/London
BEGIN:DAYLIGHT
TZOFFSETFROM:+0000
TZOFFSETTO:+0100
TZNAME:BST
DTSTART:19700329T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0100
TZOFFSETTO:+0000
TZNAME:GMT
DTSTART:19701025T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:{{ now|date(&quot;Ymd&quot;, &quot;UTC&quot;) }}T{{ now|date(&quot;Hi&quot;, &quot;UTC&quot;) }}00Z
UID:{{ entry.id }}@example.com
DTSTART;TZID=Europe/London:{{ startDate|date(&quot;Ymd&quot;, &quot;UTC&quot;) }}T{{ startDate|date(&quot;Hi&quot;, &quot;UTC&quot;) }}00
SUMMARY:{{ entry.title }}
URL:{{ entry.url }}
END:VEVENT
END:VCALENDAR
{% endapply %}
</code></pre>

CC BY-SA 4.0
2023-10-30T17:43:50.103Z
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
CC BY-SA 4.0
2023-10-31T10:48:40.067Z
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.
James Smith
CC BY-SA 4.0
2023-10-31T15:13:02.140Z
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
CC BY-SA 4.0
2023-10-31T17:14:43.267Z
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.
James Smith
CC BY-SA 4.0
2023-10-31T21:17:51.317Z
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
CC BY-SA 4.0
2023-10-31T23:19:37.290Z
This code isn't going to work, though, if any of the text you output exceeds the max of 75 octets per line, or if the linefeeds are not what the spec requires (`\r\n`), and other annoying bits. Spec here: https://datatracker.ietf.org/doc/html/rfc2445 The iCalendar plugin I wrote handles this for you: https://plugins.craftcms.com/icalendar
andrew.welch
CC BY-SA 4.0
2023-11-01T07:52:31.437Z
@PaulFrost you can fetch your entry data however you like. In this example I'm using /events/ical/{entry-slug} so that I can use the last segment to get the entry. If you prefer you could use a GET parameter, or an ID, or anything else you can imagine.
James Smith
CC BY-SA 4.0
2023-11-01T09:08:20.620Z
@andrew.welch All I can say is that I've had this code in production for over 5 years without complaint, and so far it's working perfectly. I wasn't aware of the line length limitation, so thanks for that.
James Smith
CC BY-SA 4.0
2023-11-01T09:21:12.000Z
@andrew.welch I looked at your plugin before, but I couldn't see an example of it generating an ics file from an entry. I thought it was more about reading an ics file and presenting the contents. Can you give an example of the code I would use on the entry page template and the ics file it would link to using your plugin?
Paul Frost
CC BY-SA 4.0
2023-11-01T17:23:05.157Z
Example right here in the documentation @PaulFrost -> https://nystudio107.com/docs/icalendar/#formatting-rfc-2445-text-in-twig
andrew.welch
CC BY-SA 4.0
2023-11-01T17:26:15.937Z
@JamesSmith Some ics parsers are more forgiving than others; and your specific case may work just based on the inputs just happening to conform. But the transformation you need to do in order to adhere to the spec is more involved: https://github.com/nystudio107/craft-icalendar/blob/develop-v4/src/services/Convert.php#L41
andrew.welch
CC BY-SA 4.0
2023-11-01T21:03:52.507Z
@andrew.welch, but that example doesn't show the entry query to retreive/create the data. I'm wanting to have a link on every event details page that creates the ics file with that events details and downloads it to the visitor.
Paul Frost
CC BY-SA 4.0
2023-11-02T09:28:49.530Z
@PaulFrost Just generate a link to your ical template and include a reference to the entry - either using the slug or the id - something like this: `<a href="/events/ical/ ~ {{ entry.slug }}">Add this event to your calendar</a>`. If you're using a URL segment instead of a GET parameter you'll need to create a custom route in config/routes.php (e.g. `'events/ical/<entrySlug:{slug}>' => ['template' => '_entries/eventIcal']`). If you want to use a GET parameter you won't need a route - something like this: `<a href="/events/ical?slug= ~ {{ entry.slug }}">Add this event to your calendar</a>`
James Smith
CC BY-SA 4.0
2023-11-02T09:45:04.207Z
...and then in your ical template don't forget to amend the entry query accordingly to fetch it based on either your route parameter or the GET parameter. The former would be `craft.entries.section('events').slug(entrySlug).one()`, the latter would be `craft.entries.section('events').slug(craft.app.request.param('slug')).one()`.
James Smith
CC BY-SA 4.0
2023-11-03T14:56:17.590Z
@JamesSmith I must be stupid as I can't get this to work. I'm trying the GET option you gave, but the entries query isn't working. If I turn on DEV mode I get "Impossible to access an attribute ("title") on a null variable" if I'm in Production mode, an ics file is downloaded but it's not got the entry data in it. I have
`<a href="/events/ical?slug= ~ {{ entry.slug }}">Add this event to your calendar</a>` in the entry template and 
`craft.entries.section('events').slug(craft.app.request.param('slug')).one()`
in the ics.twig template, they are both in the same template folder.
Paul Frost
CC BY-SA 4.0
2023-10-30T09:14:54.520Z
2023-10-30T09:14:54.520Z
James Smith
<p>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 &quot;Click to add to your calendar&quot; 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.</p>

<untagged>
How to code a downloadable ICS file for an event
# How to code a downloadable ICS file for an event

(Asked by: username_0 on 2023-10-29)

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.

**Tags:** untagged

---

## 3 answers

### Answer by username_1 on 2023-10-30. Score: 2

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)
        ->productIdentifier($currentSite->getBaseUrl());

    $event = Event::create()
        ->name($entry->title)
        ->startsAt($entry->start_date)
        ->endsAt($entry->end_date)
        ->description($entry->description)
        ->address($entry->address)
        ->url($entry->getUrl());

    $calendar->event($event);

    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:

    {{ 'Download event as ICS'|t }}


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

> **Comments:**
> **username_2**: Interesting alternative approach here! I'm always curious where exactly that tipping point is for reaching for a third-party library.  
> **username_1**: @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 ^^  
> **username_3**: Yeah the Spatie library is a good way to go as well. RFC 2445 is too annoying to do correctly via Twig imo.  

---

### Answer by username_3 on 2023-10-31. Score: 1

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>


---

### Answer by username_2 on 2023-10-30. Score: 1

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" %}
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//{{ siteName }}//NONSGML v1.0//EN
X-WR-CALNAME:{{ entry.title }}
NAME:{{ entry.title }}
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/London
TZURL:http://tzurl.org/zoneinfo-outlook/Europe/London
X-LIC-LOCATION:Europe/London
BEGIN:DAYLIGHT
TZOFFSETFROM:+0000
TZOFFSETTO:+0100
TZNAME:BST
DTSTART:19700329T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0100
TZOFFSETTO:+0000
TZNAME:GMT
DTSTART:19701025T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
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 }}
END:VEVENT
END:VCALENDAR
{% endapply %}

> **Comments:**
> **username_0**: 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.  
> **username_2**: 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.  
> **username_0**: 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.  
> **username_2**: 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.  
> **username_0**: 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?  
> **username_3**: This code isn't going to work, though, if any of the text you output exceeds the max of 75 octets per line, or if the linefeeds are not what the spec requires (`\r\n`), and other annoying bits. Spec here: https://datatracker.ietf.org/doc/html/rfc2445 The iCalendar plugin I wrote handles this for you: https://plugins.craftcms.com/icalendar  
> **username_2**: @PaulFrost you can fetch your entry data however you like. In this example I'm using /events/ical/{entry-slug} so that I can use the last segment to get the entry. If you prefer you could use a GET parameter, or an ID, or anything else you can imagine.  
> **username_2**: @andrew.welch All I can say is that I've had this code in production for over 5 years without complaint, and so far it's working perfectly. I wasn't aware of the line length limitation, so thanks for that.  
> **username_0**: @andrew.welch I looked at your plugin before, but I couldn't see an example of it generating an ics file from an entry. I thought it was more about reading an ics file and presenting the contents. Can you give an example of the code I would use on the entry page template and the ics file it would link to using your plugin?  
> **username_3**: Example right here in the documentation @PaulFrost -> https://nystudio107.com/docs/icalendar/#formatting-rfc-2445-text-in-twig  
> **username_3**: @JamesSmith Some ics parsers are more forgiving than others; and your specific case may work just based on the inputs just happening to conform. But the transformation you need to do in order to adhere to the spec is more involved: https://github.com/nystudio107/craft-icalendar/blob/develop-v4/src/services/Convert.php#L41  
> **username_0**: @andrew.welch, but that example doesn't show the entry query to retreive/create the data. I'm wanting to have a link on every event details page that creates the ics file with that events details and downloads it to the visitor.  
> **username_2**: @PaulFrost Just generate a link to your ical template and include a reference to the entry - either using the slug or the id - something like this: `<a href="/events/ical/ ~ {{ entry.slug }}">Add this event to your calendar</a>`. If you're using a URL segment instead of a GET parameter you'll need to create a custom route in config/routes.php (e.g. `'events/ical/<entrySlug:{slug}>' => ['template' => '_entries/eventIcal']`). If you want to use a GET parameter you won't need a route - something like this: `<a href="/events/ical?slug= ~ {{ entry.slug }}">Add this event to your calendar</a>`  
> **username_2**: ...and then in your ical template don't forget to amend the entry query accordingly to fetch it based on either your route parameter or the GET parameter. The former would be `craft.entries.section('events').slug(entrySlug).one()`, the latter would be `craft.entries.section('events').slug(craft.app.request.param('slug')).one()`.  
> **username_0**: @JamesSmith I must be stupid as I can't get this to work. I'm trying the GET option you gave, but the entries query isn't working. If I turn on DEV mode I get "Impossible to access an attribute ("title") on a null variable" if I'm in Production mode, an ics file is downloaded but it's not got the entry data in it. I have `<a href="/events/ical?slug= ~ {{ entry.slug }}">Add this event to your calendar</a>` in the entry template and  `craft.entries.section('events').slug(craft.app.request.param('slug')).one()` in the ics.twig template, they are both in the same template folder.  

---

