calendar / icsfile
This simple class generate a .ics file.
Requires
- php: >=8.5
Requires (Dev)
- deptrac/deptrac: ^4.7
- friendsofphp/php-cs-fixer: ^v3.85
- infection/infection: ^0.35
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^13.3
- rector/rector: ^2.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 12.1.0
- 12.0.1
- 12.0.0
- 11.0.0
- 10.3.0
- 10.2.0
- 10.1.0
- 10.0.2
- 10.0.1
- 10.0.0
- 9.7.1
- 9.7.0
- 9.6.0
- 9.5.0
- 9.4.0
- 9.3.0
- 9.2.3
- 9.2.2
- 9.2.1
- 9.2.0
- 9.1.0
- 9.0.0
- 8.0.0
- 7.1.0
- 7.0.0
- 6.3.2
- 6.3.1
- 6.3.0
- 6.2.0
- 6.1.2
- 6.1.1
- 6.1.0
- 6.0.3
- 6.0.2
- 6.0.1
- 6.0.0
- 5.4.2
- 5.4.1
- 5.4.0
- 5.3.0
- 5.2.0
- 5.1.4
- 5.1.3
- 5.1.2
- 5.1.1
- 5.1.0
- 5.0.0
- 4.0.1
- 4.0.0
- 3.2.3
- 3.2.2
- 3.2.1
- 3.2.0
- 3.1.3
- 3.1.2
- 3.1.1
- 3.1.0
- 3.0.0
- 2.3.0
- 2.2.5
- 2.2.4
- 2.2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.5
- 2.1.4
- 2.1.3
- 2.1.2
- 2.1.1
- 2.1.0
- 2.0.0
- 1.1.1
- 1.1.0
- dev-feature/code-use-ddd
- dev-Luc-Sanchez/add-rector-dev-dependancy-1759416547629
This package is auto-updated.
Last update: 2026-10-31 18:55:18 UTC
README
A dependency-free PHP library that generates iCalendar (.ics) files following RFC 5545: events, to-dos, journal entries and free/busy time, including invitations, replies and cancellations (iTIP, RFC 5546).
Upgrading from 11.x or earlier? See UPGRADE.md: 12.0 moves the domain classes to
Ical\Domain\Entity,InterfaceandValueObject, removes the deprecatedsetRepeat()/getRepeat()and non-Gregorian calendar types, and adds to-dos, journal entries, free/busy time and most of RFC 5545.
Table of contents
- Quick start
- Dates and timezones
- Recurring events
- Event details
- Several events in one calendar
- Invitations (iTIP)
- Output
- Error handling
- RFC 5545 compliance
- Architecture
- API reference
Quick start
Installation
PHP 8.5 or later is required (native Uri\Rfc3986\Uri), with no other dependency.
composer require calendar/icsfile
A first event
Each Ical object is one event (VEVENT): set a start, a summary, and send the file.
<?php
require_once 'vendor/autoload.php';
use Ical\Exceptions\IcalendarException;
use Ical\Ical;
$paris = new \DateTimeZone('Europe/Paris');
try {
$ical = new Ical()
->setUid('team-meeting-2025-06-10@example.com') // keep it to update the event later
->setSummary('Team meeting')
->setAddress('Room 4, Paris')
->setDateStart(new \DateTimeImmutable('2025-06-10 09:00', $paris)) // the only required date
->setDateEnd(new \DateTimeImmutable('2025-06-10 10:00', $paris))
->setFilename('team-meeting');
$ical->addHeader(); // Content-Type and Content-Disposition: the browser downloads team-meeting.ics
echo $ical->getICAL();
} catch (IcalendarException $exception) {
echo $exception->getMessage(); // an invalid value or combination of properties
}
BEGIN:VCALENDAR
PRODID:-//icalendar-generator//EN
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:team-meeting-2025-06-10@example.com
DTSTAMP:20250601T080000Z
DTSTART:20250610T070000Z
DTEND:20250610T080000Z
SUMMARY:Team meeting
LOCATION:Room 4\, Paris
TRANSP:OPAQUE
SEQUENCE:0
END:VEVENT
END:VCALENDAR
- The setters are fluent, validate their value and can be called in any order;
getICAL()checks the whole event and never returns a file that breaks RFC 5545. - Dates are written in UTC by default (
09:00in Paris is07:00Z), see local time to keep theTZID. DTSTAMPis the current time unlesssetDateStamp()is called.
Common tasks
| To… | Use | Details |
|---|---|---|
| Create an all-day event | setAllDay(new \DateTimeImmutable('2025-12-25')) instead of setDateStart() | All-day events |
| Repeat an event | setRrule('FREQ=WEEKLY;COUNT=10') | Recurring events |
Keep the local time (TZID) | setTimezoneICal('Europe/Paris')->setAutoVtimezone(true) and setDateStart($date, normalizeToUTC: false) | Dates and timezones |
| Add a reminder | setAlarmMinutesBefore(30) | Alarms |
| Put several events in one file | new Calendar()->addEvent($first, $second), then getICAL() on the calendar | Several events in one calendar |
| Send an invitation | setOrganizer('boss@example.com'), addAttendee(new Attendee(new Email('john@example.com'))), setMethod(MethodEnum::REQUEST) | Invitations (iTIP) |
| Reply to or cancel an invitation | ReplyToInvitation, CancelEvent, RescheduleOccurrence | Replying, cancelling, rescheduling |
| Return the file from a framework | $file = $ical->toIcsFile(); then new Response($file->content, 200, HttpHeaders::forFile($file)) | Downloading the file |
| Add to-dos, journal entries, free/busy | Calendar::addTodo(), addJournal(), addFreeBusy() | To-dos, journal entries and free/busy time |
| Get a deterministic output in tests | IcalGenerator::generate($ical, null, $clock, $uidGenerator) | Clock and UID generator |
The rest of this document is the detailed guide, by topic, followed by the API reference.
Dates and timezones
Start, end and timestamp
setDateStart()is required (DTSTART), unless the event is an all-day event.- The end is optional: either
setDateEnd()(DTEND, strictly after the start) orsetDuration()(DURATION, see Description and classification), not both. setDateStamp()setsDTSTAMP, the creation time of the iCalendar object. It defaults to the current time, shared by all the events of a file, and is always written in UTC (RFC 5545 §3.8.7.2).
Dates are stored with their own timezone and only formatted when the file is generated, so the order of the setters does not matter.
UTC or local time
By default, dates are converted to UTC and written with a trailing Z, whatever the timezone of the server:
$paris = new \DateTimeImmutable('2025-10-02 10:00:00', new \DateTimeZone('Europe/Paris'));
$ical->setDateStart($paris); // DTSTART:20251002T080000Z
To keep the local time, pass normalizeToUTC: false: the date is converted to the setTimezoneICal() timezone (10:00 in New York becomes 16:00 in Paris) and written with a TZID parameter. RFC 5545 forbids a TZID without its VTIMEZONE definition, so this only happens when a matching VTIMEZONE is generated, automatically or manually; otherwise the date is still written in UTC:
$ical->setTimezoneICal('Europe/Paris')->setAutoVtimezone(true)
->setDateStart($paris, normalizeToUTC: false); // DTSTART;TZID=Europe/Paris:20251002T100000
$ical->setDateStart($paris, normalizeToUTC: false); // without VTIMEZONE: DTSTART:20251002T080000Z
setTimezoneICal() must be a timezone known by PHP.
Floating time
A date in floating time (RFC 5545 §3.3.5, form #1) is written without Z nor TZID: it is the same clock time in every timezone, e.g. an alarm clock at 07:00 wherever you are. Pass floating: true; the clock time of the date is kept as given, whatever its timezone, normalizeToUTC and setTimezoneICal(), and no VTIMEZONE is needed:
$ical->setDateStart(new \DateTimeImmutable('2025-01-06 07:00:00'), floating: true)
->setDateEnd(new \DateTimeImmutable('2025-01-06 07:30:00'), floating: true)
->setRrule('FREQ=DAILY;UNTIL=20250131T070000');
// DTSTART:20250106T070000
// DTEND:20250106T073000
// RRULE:FREQ=DAILY;UNTIL=20250131T070000
DTENDmust be floating if and only ifDTSTARTis (§3.8.2.2), which is checked bygetICAL(); their order is compared on the clock times.RDATE,EXDATEandRECURRENCE-IDfollowDTSTART: they are written with the clock time of each date as given. The occurrences of a recurring event in floating time must be floating too (§3.8.4.4).- The
UNTILof theRRULEis then a local date-timeYYYYMMDDTHHMMSS, withoutZ(§3.3.10). DTSTAMP,CREATED,LAST-MODIFIEDand the date-time of an alarmTRIGGERstay in UTC, as RFC 5545 requires.- To-dos and journal entries take the same
floating: trueinsetStart()andsetDue()(to-dos);DUEmust be floating if and only ifDTSTARTis (§3.8.2.3).
Automatic VTIMEZONE
setAutoVtimezone(true) generates the VTIMEZONE (RFC 5545 §3.6.5) from the PHP timezone database:
$paris = new \DateTimeZone('Europe/Paris');
$ical = new Ical()
->setTimezoneICal('Europe/Paris')
->setAutoVtimezone(true)
->setDateStart(new \DateTimeImmutable('2025-06-10 09:00:00', $paris), normalizeToUTC: false)
->setDateEnd(new \DateTimeImmutable('2025-06-10 10:00:00', $paris), normalizeToUTC: false)
->setRrule('FREQ=WEEKLY');
// BEGIN:VTIMEZONE
// TZID:Europe/Paris
// BEGIN:DAYLIGHT
// DTSTART:20250330T020000
// TZOFFSETFROM:+0100
// TZOFFSETTO:+0200
// TZNAME:CEST
// RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
// END:DAYLIGHT
// BEGIN:STANDARD
// DTSTART:20251026T030000
// ...
// DTSTART;TZID=Europe/Paris:20250610T090000
- Disabled by default.
- Only the changes of time between the first and the last instant of the event are described (
DTSTART, end orDURATION,RDATE,EXDATE,RRULEUNTIL). An event recurring forever (RRULEwithoutUNTIL, or withCOUNT) gets the rules still applied, recurring without end. - Consecutive yearly changes following the same rule are merged into one observance with a
RRULE; irregular changes and rule changes (e.g. United States in 2007) get their own observances. A timezone without daylight saving time (Asia/Tokyo) gets a singleSTANDARD. - No
VTIMEZONEis added when no date is written in local time (UTC dates, all-day events,UTCtimezone). - In a
Calendar, oneVTIMEZONEis written per timezone, covering all the events using it.
Manual VTIMEZONE
The setVtimezone*() setters define the VTIMEZONE yourself; it takes precedence over the automatic one:
$ical = new Ical()
->setTimezoneICal('Europe/Paris')
->setVtimezoneTzid('Europe/Paris')
->setVtimezoneStandardTzname('CET')
->setVtimezoneStandardTzoffsetfrom('+0200')
->setVtimezoneStandardTzoffsetto('+0100')
->setVtimezoneStandardDtstart('19701025T030000')
->setVtimezoneStandardRrule('FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10')
->setVtimezoneDaylightTzname('CEST')
->setVtimezoneDaylightTzoffsetfrom('+0100')
->setVtimezoneDaylightTzoffsetto('+0200')
->setVtimezoneDaylightDtstart('19700329T020000')
->setVtimezoneDaylightRrule('FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3');
setVtimezoneTzurl($url) adds the TZURL (§3.8.3.5), where the definition is published.
VTIMEZONE given as a whole
The setters define one STANDARD and one DAYLIGHT period. setVtimezone() takes a whole TimezoneDefinition instead, with as many periods as the history of the timezone needs; it takes precedence over the setters and over the automatic VTIMEZONE:
use Ical\Domain\ValueObject\{LocalDateTime, RecurrenceRule, TimezoneDefinition, TimezonePeriod, UtcOffset};
$ical->setTimezoneICal('America/New_York')->setVtimezone(new TimezoneDefinition(
'America/New_York',
[
// isDaylight, onset (local time before the change), TZOFFSETFROM, TZOFFSETTO, TZNAME, RRULE
new TimezonePeriod(true, new LocalDateTime('20070311T020000'), new UtcOffset('-0500'), new UtcOffset('-0400'), 'EDT',
RecurrenceRule::forTimezoneObservance('FREQ=YEARLY;BYMONTH=3;BYDAY=2SU')),
new TimezonePeriod(false, new LocalDateTime('20071104T020000'), new UtcOffset('-0400'), new UtcOffset('-0500'), 'EST',
RecurrenceRule::forTimezoneObservance('FREQ=YEARLY;BYMONTH=11;BYDAY=1SU'),
comments: ['Eastern Standard Time'], localizedNames: ['fr' => 'HNE']),
// onsets given one by one (RDATE) instead of a rule
new TimezonePeriod(true, new LocalDateTime('19740106T020000'), new UtcOffset('-0500'), new UtcOffset('-0400'), 'EDT',
rdates: [new LocalDateTime('19750223T020000')]),
],
lastModified: new \DateTimeImmutable('2005-08-09 05:00:00', new \DateTimeZone('UTC')), // LAST-MODIFIED
));
TimezonePeriod:rdatesare other onsets (RDATE, in local time like the onset),localizedNamesotherTZNAMEby language tag (TZNAME;LANGUAGE=fr:HNE),commentstheCOMMENTlines (§3.6.5). TheUNTILof its rule must be a UTC date-time, even for a rule built withnew RecurrenceRule().TimezoneDefinition:urlis theTZURL,lastModifiedtheLAST-MODIFIED, written in UTC.- Its
TZIDmust be thesetTimezoneICal()timezone, which is checked bygetICAL();setIncludeVtimezone(false)ignores it,setVtimezone(null)removes it. In aCalendar, two events cannot give different definitions for the sameTZID.
It is written only when the TZID matches setTimezoneICal() and both STANDARD and DAYLIGHT have their Dtstart, Tzoffsetfrom and Tzoffsetto; otherwise the dates are written in UTC. setIncludeVtimezone(false) disables both the manual and the automatic VTIMEZONE.
The values are validated when set:
Tzoffsetfrom/Tzoffsetto:±HHMMor±HHMMSS(hours 00-23, minutes and seconds 00-59), not-0000(§3.3.14);Dtstart: local date-timeYYYYMMDDTHHMMSS, withoutZ(§3.6.5);Tzname: optional, theTZNAMEline is omitted when empty;Rrule: optional, a valid recurrence rule whoseUNTILis a UTC date-time such as20061029T060000Z.
All-day events
Use setAllDay() instead of setDateStart() / setDateEnd() for events that last whole days (holidays, birthdays, conferences…). The dates are written as DATE values, without time or timezone (RFC 5545 §3.6.1):
// One day
$ical->setAllDay(new \DateTimeImmutable('2025-12-25'));
// → DTSTART;VALUE=DATE:20251225
// → DTEND;VALUE=DATE:20251226
// From June 28 to July 8 included
$ical->setAllDay(new \DateTimeImmutable('2025-06-28'), new \DateTimeImmutable('2025-07-08'));
// → DTSTART;VALUE=DATE:20250628
// → DTEND;VALUE=DATE:20250709
- The last day is included and defaults to the first day. The generated
DTENDis the day after, because RFC 5545 defines it as exclusive;getAllDayEnd()returns this exclusive value. - Only the calendar date is used, as seen in the timezone of the given object:
2025-12-25 23:30inAmerica/New_Yorkgives20251225. The time andsetTimezoneICal()are ignored. - The last day cannot be before the first day.
- An all-day event cannot also have
setDateStart()orsetDateEnd(). - Without a last day,
setDuration()gives the length in days or weeks instead of theDTEND(§3.6.1):setAllDay(new \DateTimeImmutable('2025-08-01'))->setDuration('P2W')givesDTSTART;VALUE=DATE:20250801andDURATION:P2W. A duration with hours (PT24H,P1DT12H) or a last day given with a duration is rejected bygetICAL().
Recurring events
Recurrence rule (RRULE)
setRrule() makes the event repeat. Pass the rule without the RRULE: prefix (RFC 5545 §3.3.10); an empty string removes it.
// Every Monday, 10 times
$ical->setDateStart(new \DateTimeImmutable('2025-01-06 09:00:00', new \DateTimeZone('UTC')))
->setRrule('FREQ=WEEKLY;BYDAY=MO;COUNT=10');
// → RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10
// Last working day of each month, until the end of 2025
$ical->setRrule('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;UNTIL=20251231T235959Z');
// Every year on December 25 (all-day event)
$ical->setAllDay(new \DateTimeImmutable('2025-12-25'))
->setRrule('FREQ=YEARLY;UNTIL=20301225');
The rule is validated by setRrule():
FREQis required and must be the first rule part; every rule part may appear only once;UNTILandCOUNTcannot be combined;COUNTandINTERVALmust be positive integers;- numeric values must be in range (e.g.
BYMONTH1 to 12,BYHOUR0 to 23,BYDAYordinal ±1 to ±53); - rule parts that RFC 5545 forbids for a given
FREQare rejected (e.g.BYWEEKNOwithoutFREQ=YEARLY,BYDAY=1MOwithFREQ=WEEKLY,BYSETPOSwithout anotherBYxxxpart).
Its consistency with the start date is checked by getICAL(), since the start date may be set after the rule:
| Start date | UNTIL must be | Forbidden rule parts |
|---|---|---|
setDateStart() (UTC or local time with TZID) | a UTC date-time, e.g. 20251231T235959Z | — |
setAllDay() | a date, e.g. 20251231 | BYHOUR, BYMINUTE, BYSECOND |
Excluded and additional occurrences (EXDATE, RDATE)
setExdates() removes occurrences from the recurrence (RFC 5545 §3.8.5.1) and setRdates() adds isolated ones (§3.8.5.2):
$paris = new \DateTimeZone('Europe/Paris');
$ical->setDateStart(new \DateTimeImmutable('2025-01-06 09:00:00', $paris))
->setRrule('FREQ=WEEKLY;BYDAY=MO;COUNT=10')
->setExdates( // no meeting on these Mondays
new \DateTimeImmutable('2025-01-20 09:00:00', $paris),
new \DateTimeImmutable('2025-01-13 09:00:00', $paris),
)
->setRdates(new \DateTimeImmutable('2025-01-08 09:00:00', $paris)); // extra meeting on a Wednesday
// → RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10
// → RDATE:20250108T080000Z
// → EXDATE:20250113T080000Z,20250120T080000Z
- Each call replaces the previous dates; call it without argument to remove them.
- The dates are written in the same form as the start date, so that they match the occurrences: UTC, local time with
TZID, orDATEvalues for an all-day event. A date given in another timezone is converted. - The values are sorted and duplicates are removed.
- An
EXDATEmust be the start of an occurrence to remove it, and requires anRRULEor anRDATE.RDATEalone makes the event recurring.
setRdatePeriods() adds occurrences with their own length (RDATE;VALUE=PERIOD, §3.3.9), as Ical\Domain\ValueObject\Period objects:
use Ical\Domain\ValueObject\Period;
$ical->setRdatePeriods(
Period::between(new \DateTimeImmutable('2025-01-09 14:00:00', $paris), new \DateTimeImmutable('2025-01-09 16:30:00', $paris)),
Period::startingAt(new \DateTimeImmutable('2025-01-10 14:00:00', $paris), 'PT2H'),
);
// → RDATE;VALUE=PERIOD:20250109T130000Z/20250109T153000Z,20250110T130000Z/PT2H
Period::between()requires an end later than the start,Period::startingAt()a positive duration.- The periods are written on their own
RDATEline, their bounds in the same form as the start date (UTC or local time withTZID), sorted and without duplicates; the automatic VTIMEZONE covers them. - Like
setRdates(), each call replaces the previous periods, and periods alone make the event recurring. An all-day event cannot have periods (their bounds are date-times).
Modifying one occurrence (RECURRENCE-ID)
To move, rename or change a single occurrence, add a second Ical to the calendar with the same UID and setRecurrenceId() set to the original start of the occurrence (RFC 5545 §3.8.4.4); its own dates and properties are the new ones:
use Ical\Calendar;
use Ical\Ical;
$utc = new \DateTimeZone('UTC');
$calendar = new Calendar()->addEvent(
new Ical() // every Monday at 09:00
->setUid('weekly@example.com')
->setSummary('Weekly')
->setDateStart(new \DateTimeImmutable('2025-01-06 09:00:00', $utc))
->setDateEnd(new \DateTimeImmutable('2025-01-06 10:00:00', $utc))
->setRrule('FREQ=WEEKLY;BYDAY=MO;COUNT=10'),
new Ical() // except Monday 13, moved to Tuesday 14 at 14:00
->setUid('weekly@example.com')
->setSummary('Weekly (moved)')
->setDateStart(new \DateTimeImmutable('2025-01-14 14:00:00', $utc))
->setDateEnd(new \DateTimeImmutable('2025-01-14 15:00:00', $utc))
->setSequence(1)
->setRecurrenceId(new \DateTimeImmutable('2025-01-13 09:00:00', $utc)),
);
// → DTSTART:20250114T140000Z
// → DTEND:20250114T150000Z
// → RECURRENCE-ID:20250113T090000Z
setRecurrenceId($originalStart, thisAndFuture: true)applies the change to the following occurrences too (RANGE=THISANDFUTURE);setRecurrenceId(null)makes the event a main event again.- The value is written in the same form as the start date, like
EXDATE(RECURRENCE-ID;VALUE=DATE:20250113for an all-day event). - An occurrence can be generated alone (e.g. to reply to a single occurrence). When the main event is in the same calendar, it must recur, both must be all-day or both date-time, and an occurrence cannot be modified twice.
Event details
A complete event
An event using the most common properties, each detailed in the sections below:
<?php
require_once 'vendor/autoload.php';
use Ical\Enum\StatusEnum;
use Ical\Enum\TransparencyEnum;
use Ical\Exceptions\IcalendarException;
use Ical\Ical;
$utc = new \DateTimeZone('UTC');
try {
$ical = new Ical()
->setProdId('Bob Morane', 'Conference Calendar', 'FR')
->setUid('running-2025@example.com') // Optional, random when not set
->setSummary('Running')
->setDescription('wonder description')
->setAddress('Paris')
->setDateStart(new \DateTimeImmutable('2025-06-10 15:00:00', $utc)) // Required
->setDateEnd(new \DateTimeImmutable('2025-06-10 16:00:00', $utc)) // Optional, must be after the start
->setDateStamp(new \DateTimeImmutable('2025-06-01 08:00:00', $utc)) // Optional, defaults to now
->setOrganizer('foo@example.com', 'Foo Bar')
->setStatus(StatusEnum::CONFIRMED)
->setTransparency(TransparencyEnum::OPAQUE)
->setSequence(2)
->setFilename('running');
$ical->addHeader(); // Content-Type and Content-Disposition, to download the file
echo $ical->getICAL();
} catch (IcalendarException $exception) {
echo $exception->getMessage();
}
Output (lines are separated by CRLF, as RFC 5545 requires):
BEGIN:VCALENDAR
PRODID:-//Bob Morane//Conference Calendar//FR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:running-2025@example.com
DTSTAMP:20250601T080000Z
DTSTART:20250610T150000Z
DTEND:20250610T160000Z
SUMMARY:Running
ORGANIZER;CN=Foo Bar:mailto:foo@example.com
LOCATION:Paris
DESCRIPTION:wonder description
STATUS:CONFIRMED
TRANSP:OPAQUE
SEQUENCE:2
END:VEVENT
END:VCALENDAR
Identification (UID, SEQUENCE, PRODID)
setUid()gives the event a persistent identifier, so that calendar applications can update it later. Without it, a random UID is generated on each call (see Clock and UID generator);getICAL($uid)overrides it.setSequence()is the revision number (>= 0, default0): increase it each time you send an updated version of the event.setProdId()identifies the application that generated the file, in the format-//Organisation//Product//LANGrequired by RFC 5545:
$ical->setProdId('Bob Morane', 'Conference Calendar', 'FR');
// → PRODID:-//Bob Morane//Conference Calendar//FR
$ical->setProdId('My Company', 'Booking App');
// → PRODID:-//My Company//Booking App//EN
Without setProdId(), the setName() value is used as a fallback, then -//icalendar-generator//EN.
CALSCALE:GREGORIAN is always written: it is the only value allowed by RFC 5545 §3.7.1. CalendarTypeEnum has only the GREGORIAN case, and setCalendarType() is kept for compatibility.
Description and classification
use Ical\Enum\ClassificationEnum;
use Ical\Enum\StatusEnum;
use Ical\Enum\TransparencyEnum;
$ical
->setSummary('Project review') // SUMMARY
->setDescription("Agenda:\n- budget") // DESCRIPTION
->setAddress('Room 4, Paris') // LOCATION
->setStatus(StatusEnum::TENTATIVE) // CONFIRMED, TENTATIVE, CANCELLED
->setTransparency(TransparencyEnum::OPAQUE) // OPAQUE (busy) or TRANSPARENT (free)
->setDateStart(new \DateTimeImmutable('2025-01-01 10:00:00', new \DateTimeZone('UTC')))
->setDuration('PT1H30M') // or Duration::fromDateInterval($interval)->value
->setClassification(ClassificationEnum::PRIVATE)
->setUrl('https://example.com/events/42')
->setCategories('Meeting', 'Project, Alpha')
->setResources('PROJECTOR', 'EASEL') // RESOURCES
->setPriority(2) // PRIORITY: 1 (highest) to 9 (lowest), 0 undefined
->setGeo(48.856614, 2.352222) // GEO: latitude, longitude
->setComments('Bring the slides') // COMMENT, one line per value
->setContacts('Jim Dolittle, +1-919-555-1234') // CONTACT, one line per value
->setCreated(new \DateTimeImmutable('2024-11-01 09:00:00'))
->setLastModified(new \DateTimeImmutable('2024-11-15 18:30:00'));
// DURATION:PT1H30M
// GEO:48.856614;2.352222
// COMMENT:Bring the slides
// CONTACT:Jim Dolittle\, +1-919-555-1234
// URL:https://example.com/events/42
// CATEGORIES:Meeting,Project\, Alpha
// RESOURCES:PROJECTOR,EASEL
// CLASS:PRIVATE
// PRIORITY:2
// CREATED:20241101T080000Z
// LAST-MODIFIED:20241115T173000Z
DURATION(RFC 5545 §3.8.2.5) is a positive duration:PnW,PnD,PnDTnH…orPTnHnMnS(hours, minutes and seconds contiguous, e.g.PT1H0M30S); months and years are not allowed.Ical\Domain\ValueObject\Duration::fromDateInterval()converts a\DateInterval.CLASS(ClassificationEnum):PUBLIC,PRIVATE,CONFIDENTIAL.URLmust be an absolute URI conforming to RFC 3986 (parsed withUri\Rfc3986\Uri, so it must have a scheme and non-ASCII characters must be percent-encoded);getUrl()returns theUri\Rfc3986\Uri, ornullwhen no URL is set, and it is written as given.setDescription($text, $altRep)andsetAddress($text, $altRep)take an optional alternate representation (ALTREP, §3.2.1), the URI of e.g. an HTML version.setLanguage('fr')sets the language of the texts (LANGUAGE, §3.2.10, a language tag such asfroren-US), written onSUMMARY,DESCRIPTION,LOCATION,COMMENT,CONTACT,CATEGORIESandRESOURCES;nullremoves it.CATEGORIESare written on one line, each one escaped; empty or blank categories are ignored, andsetCategories()without argument removes them.RESOURCES(§3.8.1.10) follow the same rules asCATEGORIES;COMMENT(§3.8.1.4) andCONTACT(§3.8.4.2) are written on one line per value. Each setter replaces the previous values, ignores empty or blank ones and removes the property when called without argument.PRIORITY(§3.8.1.9) is an integer from 0 to 9 (otherwiseIcalendarException);nullremoves it.GEO(§3.8.1.6) takes a latitude (-90 to 90) and a longitude (-180 to 180) in decimal degrees, written with at most 6 decimals;setGeo(null, null)removes it, and an out-of-range or a singlenullcoordinate throws anIcalendarException.getGeo()returns anIcal\Domain\ValueObject\Geo(latitude,longitude), ornull.CREATEDandLAST-MODIFIEDare always written in UTC.- An empty string (or
nullfor the enum setters) removes the property, with two exceptions:SUMMARYis always written, empty when not set, because RFC 5546 requires it inPUBLISHandREQUEST;setTransparency(null)restores the defaultTRANSP:OPAQUE.
Attachments and related components (ATTACH, RELATED-TO)
use Ical\Domain\ValueObject\Attachment;
use Ical\Domain\ValueObject\Relation;
use Ical\Enum\RelationTypeEnum;
$ical
->setAttachments(
Attachment::fromUrl('https://example.com/agenda.pdf', 'application/pdf'),
Attachment::fromContent(file_get_contents('slides.pdf'), 'application/pdf'),
)
->setRelatedTo(new Relation('project-2025@example.com'), new Relation('review@example.com', RelationTypeEnum::SIBLING));
// ATTACH;FMTTYPE=application/pdf:https://example.com/agenda.pdf
// ATTACH;FMTTYPE=application/pdf;ENCODING=BASE64;VALUE=BINARY:JVBERi0xLjQK…
// RELATED-TO;RELTYPE=PARENT:project-2025@example.com
// RELATED-TO;RELTYPE=SIBLING:review@example.com
ATTACH(§3.8.1.1) is a document given by an absolute URL (Attachment::fromUrl()) or by its content (Attachment::fromContent(), not empty, written in base64), with an optional media type (FMTTYPE,type/subtype).RELATED-TO(§3.8.4.5) links to another component by its UID, with itsRELTYPE:PARENT(default),CHILDorSIBLING.- A relation to a component of the same calendar can use the UID given to its setter: it is replaced by the final UID of the component, normalized on export (e.g.
report 1→report-1@…). A relation to a component outside the file is written as given: use its final UID. - Both setters replace the previous values; call them without argument to remove them. To-dos and journal entries have the same setters.
Alarms (VALARM)
A reminder that displays "Reminder":
// 15 minutes before
$ical->setAlarm(true);
// 30 minutes before, repeated 3 times every 10 minutes
$ical->setAlarmMinutesBefore(30)->setAlarmRepeat(3, 10);
setAlarmMinutesBefore()sets the trigger offset before the start and enables the alarm.setAlarmRepeat()setsREPEATand theDURATIONbetween repetitions, as RFC 5545 requires both together: 5 minutes when no interval is given, at least 1 minute.
Other alarms (RFC 5545 §3.6.6) are added with addAlarm(), built with the Ical\Domain\ValueObject\Alarm factory matching their action:
use Ical\Domain\ValueObject\Alarm;
use Ical\Domain\ValueObject\AlarmTrigger;
use Ical\Domain\ValueObject\Email;
$ical->addAlarm(
Alarm::display(AlarmTrigger::relative('-PT5M', relatedToEnd: true), 'Wrap up')->withRepeat(2, 'PT1M'),
Alarm::audio(AlarmTrigger::at(new \DateTimeImmutable('2025-01-01 09:55:00', $paris)), 'https://example.com/bell.mp3'),
Alarm::email(AlarmTrigger::relative('-P1D'), 'Tomorrow', 'Meeting tomorrow', new Email('alice@example.com')),
);
// BEGIN:VALARM / ACTION:DISPLAY / DESCRIPTION:Wrap up / TRIGGER;RELATED=END:-PT5M / REPEAT:2 / DURATION:PT1M / END:VALARM
// BEGIN:VALARM / ACTION:AUDIO / ATTACH:https://example.com/bell.mp3 / TRIGGER;VALUE=DATE-TIME:20250101T085500Z / END:VALARM
// BEGIN:VALARM / ACTION:EMAIL / DESCRIPTION:Meeting tomorrow / SUMMARY:Tomorrow / ATTENDEE:mailto:alice@example.com / TRIGGER:-P1D / END:VALARM
| Factory | Writes |
|---|---|
Alarm::display($trigger, $description) | ACTION:DISPLAY and its DESCRIPTION (required, not blank) |
Alarm::audio($trigger, ?$soundUrl) | ACTION:AUDIO, with the sound as ATTACH (an absolute URL) or the default sound |
Alarm::email($trigger, $summary, $description, ...$emails) | ACTION:EMAIL, the subject (SUMMARY), the body (DESCRIPTION) and the recipients (ATTENDEE, at least one) |
->withAttachments(Attachment ...) | ATTACH of an EMAIL alarm, the documents attached to the email (only for EMAIL) |
->withRepeat($count, $interval) | REPEAT (at least 1) and DURATION (positive) between the repetitions |
| Trigger | Writes |
|---|---|
AlarmTrigger::relative('-PT15M') | TRIGGER:-PT15M: a signed duration (- before, + or no sign after, PT0S at the start) |
AlarmTrigger::relative('-PT5M', true) | TRIGGER;RELATED=END:-PT5M: relative to the end, which requires a DTEND or a DURATION (§3.8.6.3) |
AlarmTrigger::at($dateTime) | TRIGGER;VALUE=DATE-TIME:…Z: an absolute time, converted to UTC |
- The reminder of
setAlarm()/setAlarmMinutesBefore()comes first, then the alarms in the order they were added;getAlarms()returns them all,hasAlarm()tells whether there is one. - The
REPLYandCANCELmethods do not allow alarms.
Non-standard properties (X-*)
addXProperty() adds an experimental property (RFC 5545 §3.8.8.2) to an event, or to the calendar (§3.7), for the extensions read by calendar applications:
$ical
->addXProperty('X-ALT-DESC', '<p>Agenda, <b>budget</b></p>', ['FMTTYPE' => 'text/html'])
->addXProperty('X-MICROSOFT-CDO-BUSYSTATUS', 'BUSY')
->addXProperty('X-ABC-LINK', 'https://example.com/a,b', ['VALUE' => 'URI']);
// X-ALT-DESC;FMTTYPE=text/html:<p>Agenda\, <b>budget</b></p>
// X-MICROSOFT-CDO-BUSYSTATUS:BUSY
// X-ABC-LINK;VALUE=URI:https://example.com/a,b
$calendar
->addXProperty('X-WR-CALNAME', 'Conference')
->addXProperty('X-WR-TIMEZONE', 'Europe/Paris');
// written after CALSCALE / METHOD, before the components
- The name must start with
X-followed by letters, digits or dashes (§3.1x-name), and the parameter names must be made of letters, digits or dashes; both are upper-cased. OtherwiseaddXProperty()throws anIcalendarException. - The value is TEXT by default and escaped like
DESCRIPTION. With aVALUEparameter of another type (URI,INTEGER, …), it is written as given, and must not contain a line break. In both cases, control characters other than tab are removed (§3.1). - Parameter values are encoded like the other parameters (RFC 6868, quoted when they contain
,,;or:). - The properties are written in the order they were added, at the end of the event (before its
VALARM); a property can be added several times.getXProperties()returns them asIcal\Domain\ValueObject\XProperty(name,value,parameters). - The calendar properties are written by
Calendaronly:Ical::getICAL()writes the X-properties of the event.
Several events in one calendar
To put several events in the same file, add them to a Calendar:
use Ical\Calendar;
use Ical\Ical;
$utc = new \DateTimeZone('UTC');
$calendar = new Calendar()
->setProdId('Bob Morane', 'Conference Calendar', 'FR')
->setFilename('conference')
->addEvent(
new Ical()
->setUid('keynote-2025@example.com')
->setSummary('Keynote')
->setDateStart(new \DateTimeImmutable('2025-06-10 09:00:00', $utc))
->setDateEnd(new \DateTimeImmutable('2025-06-10 10:00:00', $utc)),
new Ical()
->setUid('workshop-2025@example.com')
->setSummary('Workshop')
->setDateStart(new \DateTimeImmutable('2025-06-10 14:00:00', $utc))
->setDateEnd(new \DateTimeImmutable('2025-06-10 17:00:00', $utc)),
);
$calendar->addHeader();
echo $calendar->getICAL();
- The
PRODIDandMETHODof the calendar are used, those of the events are ignored. WithoutsetProdId(), thePRODIDis-//icalendar-generator//EN. - Two events cannot have the same UID (RFC 5545 §3.8.4.7), unless they form a recurring event and its modified occurrences.
- A
VTIMEZONEused by several events is written once, before the events; two differentVTIMEZONEfor the sameTZIDare rejected. - A calendar needs at least one component (RFC 5545 §3.6): an event, a to-do, a journal entry or free/busy time.
To-dos, journal entries and free/busy time
A Calendar also holds to-dos (VTODO, §3.6.2), journal entries (VJOURNAL, §3.6.3) and free/busy time information (VFREEBUSY, §3.6.4), written after the events:
use Ical\Calendar;
use Ical\Domain\Entity\FreeBusy;
use Ical\Domain\Entity\Journal;
use Ical\Domain\Entity\Todo;
use Ical\Domain\ValueObject\Alarm;
use Ical\Domain\ValueObject\AlarmTrigger;
use Ical\Domain\ValueObject\Period;
use Ical\Enum\FreeBusyTypeEnum;
use Ical\Enum\JournalStatusEnum;
use Ical\Enum\TodoStatusEnum;
$calendar = new Calendar()
->addTodo(
new Todo()
->setUid('report@example.com')
->setSummary('Write the report')
->setStart(new \DateTimeImmutable('2025-01-06 09:00:00', $paris))
->setDue(new \DateTimeImmutable('2025-01-10 18:00:00', $paris)) // or setDuration('P4D')
->setStatus(TodoStatusEnum::IN_PROCESS)
->setPercentComplete(40)
->setPriority(1)
->addAlarm(Alarm::display(AlarmTrigger::relative('-P1D', true), 'Due tomorrow')),
)
->addJournal(
new Journal()
->setUid('minutes-2025-01-06@example.com')
->setStart(new \DateTimeImmutable('2025-01-06'), allDay: true)
->setSummary('Weekly minutes')
->setDescription("1. Budget\n2. Hiring")
->setStatus(JournalStatusEnum::FINAL),
)
->addFreeBusy(
new FreeBusy()
->setUid('alice-busy@example.com')
->setOrganizer('alice@example.com')
->setStart(new \DateTimeImmutable('2025-01-06 00:00:00', $utc))
->setEnd(new \DateTimeImmutable('2025-01-11 00:00:00', $utc))
->addBusyTime(Period::startingAt(new \DateTimeImmutable('2025-01-06 08:00:00', $utc), 'PT30M'))
->addBusyTime(Period::startingAt(new \DateTimeImmutable('2025-01-06 09:00:00', $utc), 'PT1H'), FreeBusyTypeEnum::BUSY_TENTATIVE),
);
// BEGIN:VTODO … DTSTART:20250106T080000Z / DUE:20250110T170000Z … STATUS:IN-PROCESS / PERCENT-COMPLETE:40 … END:VTODO
// BEGIN:VJOURNAL … DTSTART;VALUE=DATE:20250106 … STATUS:FINAL … END:VJOURNAL
// BEGIN:VFREEBUSY … FREEBUSY;FBTYPE=BUSY:20250106T080000Z/PT30M / FREEBUSY;FBTYPE=BUSY-TENTATIVE:20250106T090000Z/PT1H … END:VFREEBUSY
Entity (Ical\Domain\Entity) | Setters |
|---|---|
| All three | setUid(), setDateStamp(), setLanguage(?string), setOrganizer($email, ?$name, ?$sentBy, ?$directory, ?$language), addAttendee(Attendee ...), setComments(string ...), setUrl(), addXProperty() |
Todo and Journal | setStart(?DateTimeInterface, bool $allDay = false, bool $normalizeToUTC = true, bool $floating = false), setRrule(), setRecurrenceId(?DateTimeInterface, bool $thisAndFuture = false), setRdates(DateTimeInterface ...), setExdates(DateTimeInterface ...), setAttachments(Attachment ...), setRelatedTo(Relation ...), setSummary(), setDescription($text, ?$altRep), setClassification(), setCategories(string ...), setContacts(string ...), setSequence(), setCreated(), setLastModified() |
Todo | setDue(?DateTimeInterface, bool $allDay = false, bool $normalizeToUTC = true, bool $floating = false), setDuration(), setCompleted(), setPercentComplete(?int) (0 to 100), setPriority(?int), setStatus(?TodoStatusEnum), setLocation($text, ?$altRep), setGeo(), setResources(string ...), addAlarm(Alarm ...) |
Journal | setStatus(?JournalStatusEnum): DRAFT, FINAL, CANCELLED |
FreeBusy | setStart(?DateTimeInterface), setEnd(?DateTimeInterface), setContact(), addBusyTime(Period, FreeBusyTypeEnum = BUSY) |
setStart()andsetDue()write a UTC date-time by default; withnormalizeToUTC: false, the local time of the timezone of the given date withTZID(a named timezone such asEurope/Paris, not an offset), and itsVTIMEZONEis always added from the PHP timezone database, covering the dates of the component (one perTZID, shared with the events; aVTIMEZONEdefined with the setters of an event is used for itsTZID); withfloating: true, the clock time of the given date withoutZnorTZID(floating time); withallDay: true, aDATE(VALUE=DATE), only the calendar date being used.RDATEandEXDATEfollow the form ofDTSTART.- The other date-times (
DTSTAMP,COMPLETED,CREATED,LAST-MODIFIED) are written in UTC, as RFC 5545 requires, and so are the dates of aVFREEBUSY(§3.6.4). WithoutsetDateStamp(),DTSTAMPis the current time, like for the events. - The setters validate their value like those of
Ical(email, URL, sequence, priority,GEO, duration, recurrence rule, percent complete, duplicate attendee); the rules between the dates are checked bygetICAL():- to-do:
DUEandDURATIONnot both,DURATIONonly with aDTSTART(in days or weeks with aDATE),DUEof the value type ofDTSTART, floating if and only ifDTSTARTis, and later; a trigger related to the start requires aDTSTART, one related to the end aDUEor aDTSTARTand aDURATION(§3.8.6.3); - to-do and journal entry: an
RRULE,RDATEorEXDATErequires aDTSTART, theUNTILof theRRULEmatching it, and anEXDATErequires anRRULEor anRDATE;RDATEandEXDATEare written in the form ofDTSTART(DATEor UTC), sorted and without duplicates; - free/busy:
DTENDlater thanDTSTART.
- to-do:
FREEBUSYlines are grouped byFBTYPE(FREE,BUSY,BUSY-UNAVAILABLE,BUSY-TENTATIVE), with their periods sorted and without duplicates (§3.8.2.6).- Each component needs its own UID, which cannot be used by an event either (§3.8.4.7); it is normalized like the UID of an event, and generated when empty.
- Like an event, a recurring to-do or journal entry can have modified occurrences: components with the same UID and
setRecurrenceId($originalStart, $thisAndFuture), written in the form of theirDTSTART(which they require). At most one of them has noRECURRENCE-ID, it must recur, and all of them are of the same type and the same value type (DATEor date-time). - With an iTIP method (
Calendar::setMethod()), they follow the rules of RFC 5546, and a message cannot mix them with events or with each other.
Invitations (iTIP)
Set the organizer, the attendees and an iTIP method (RFC 5546) so that Outlook, Gmail or Apple Calendar handle the file as an invitation:
use Ical\Domain\Entity\Attendee;
use Ical\Domain\ValueObject\Email;
use Ical\Enum\AttendeeRoleEnum;
use Ical\Enum\MethodEnum;
use Ical\Enum\ParticipationStatusEnum;
$ical = new Ical()
->setUid('team-meeting-2025@example.com')
->setSummary('Team meeting')
->setDateStart(new \DateTimeImmutable('2025-06-10 09:00:00', new \DateTimeZone('UTC')))
->setOrganizer('boss@example.com', 'The Boss')
->addAttendee(
new Attendee(new Email('john@example.com'), 'John Doe', AttendeeRoleEnum::REQ_PARTICIPANT, ParticipationStatusEnum::NEEDS_ACTION, rsvp: true),
new Attendee(new Email('jane@example.com'), 'Jane Doe', AttendeeRoleEnum::OPT_PARTICIPANT),
)
->setMethod(MethodEnum::REQUEST);
$ical->addHeader(); // Content-Type: text/calendar; charset=utf-8; method=REQUEST
echo $ical->getICAL();
// METHOD:REQUEST
// ORGANIZER;CN=The Boss:mailto:boss@example.com
// ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=John Doe:mailto:john@example.com
// ATTENDEE;ROLE=OPT-PARTICIPANT;CN=Jane Doe:mailto:jane@example.com
Organizer
setOrganizer($email, $name, $sentBy, $directory, $language) writes the ORGANIZER, with the optional name as CN parameter; some clients such as Google Calendar require it to accept updates. The other optional values are who acts on its behalf (SENT-BY, an email), its directory entry (DIR, a URI such as ldap://…) and the language of its name (LANGUAGE).
- The name is encoded as a parameter value (RFC 5545 §3.2 and RFC 6868): it is quoted when it contains
,,;or:, and",^and line breaks are encoded as^',^^and^n:setOrganizer('jean@example.com', 'Dupont, "Jean"')→ORGANIZER;CN="Dupont, ^'Jean^'":mailto:jean@example.com. - The email is written as a
mailto:URI (RFC 6068), with the characters not allowed in a URI percent-encoded:setOrganizer('sales&support@example.com')→ORGANIZER:mailto:sales%26support@example.com.
Attendees
new Attendee(Email $email, ?string $name = null, ?AttendeeRoleEnum $role = null, ?ParticipationStatusEnum $participationStatus = null, ?bool $rsvp = null, ?CalendarUserTypeEnum $calendarUserType = null, array $members = [], array $delegatedTo = [], array $delegatedFrom = [], ?Email $sentBy = null, ?Url $directory = null, ?Language $language = null):
- an email can only be added once to an event (case-insensitive);
- a
nullparameter is not written, calendar applications then use the RFC 5545 default (REQ-PARTICIPANT,NEEDS-ACTION,RSVP=FALSE,INDIVIDUAL); AttendeeRoleEnum:CHAIR,REQ_PARTICIPANT,OPT_PARTICIPANT,NON_PARTICIPANT;ParticipationStatusEnum:NEEDS_ACTION,ACCEPTED,DECLINED,TENTATIVE,DELEGATED;CalendarUserTypeEnum:INDIVIDUAL,GROUP,RESOURCE,ROOM,UNKNOWN;members(MEMBER, the groups of the attendee),delegatedTo/delegatedFrom(DELEGATED-TO/DELEGATED-FROM) are lists ofEmail, written as quotedmailto:URIs;sentBy(SENT-BY),directory(DIR, anIcal\Domain\ValueObject\Url) andlanguage(LANGUAGE, anIcal\Domain\ValueObject\Language) are optional;- a reply (
ReplyToInvitation) keeps these parameters, e.g.DELEGATED-TOwithPARTSTAT=DELEGATED:
new Attendee(new Email('alice@example.com'), 'Alice', participationStatus: ParticipationStatusEnum::DELEGATED, delegatedTo: [new Email('bob@example.com')]);
// ATTENDEE;PARTSTAT=DELEGATED;CN=Alice;DELEGATED-TO="mailto:bob@example.com":mailto:alice@example.com
Methods and their rules
setMethod() takes a MethodEnum; the RFC 5546 rules are checked by getICAL():
| Method | Use | Rules |
|---|---|---|
PUBLISH | Share an event, no reply expected | ORGANIZER required, no ATTENDEE |
REQUEST | Invite, or update an invitation (keep the UID, increase SEQUENCE) | ORGANIZER and at least one ATTENDEE required, STATUS cannot be CANCELLED, all events share the same UID |
REPLY | Answer of an attendee (same UID as the request) | ORGANIZER required, exactly one ATTENDEE with a PARTSTAT, no alarm, all events share the same UID |
CANCEL | Cancel the event (STATUS:CANCELLED) or remove the listed attendees (no STATUS), same UID, higher SEQUENCE | ORGANIZER and at least one ATTENDEE required (the attendees being removed, or some or all of them when the whole event is cancelled), STATUS absent or CANCELLED, no alarm, all events share the same UID |
To-dos, journal entries and free/busy times follow the rules of RFC 5546 §3.3 to §3.5; when a presence is not clearly allowed, it is rejected:
| Component | PUBLISH | REQUEST | REPLY | CANCEL |
|---|---|---|---|---|
VTODO | ORGANIZER, no ATTENDEE | ORGANIZER, at least one ATTENDEE, STATUS not CANCELLED | ORGANIZER, exactly one ATTENDEE with a PARTSTAT, no alarm | ORGANIZER, at least one ATTENDEE, STATUS absent or CANCELLED, no alarm |
VJOURNAL | ORGANIZER, no ATTENDEE | not defined | not defined | ORGANIZER, STATUS absent or CANCELLED |
VFREEBUSY | ORGANIZER, DTSTART, DTEND, FREEBUSY, no ATTENDEE | ORGANIZER, DTSTART, DTEND, at least one ATTENDEE, no FREEBUSY | ORGANIZER, DTSTART, DTEND, exactly one ATTENDEE | not defined |
REQUEST-STATUS(setRequestStatuses(new RequestStatus('2.0', 'Success')), §3.8.8.3) is only allowed in aREPLY, for every type of component.- The participation statuses
COMPLETEDandIN-PROCESS(ParticipationStatusEnum) are for to-dos only; a journal entry allows onlyNEEDS-ACTION,ACCEPTEDandDECLINED(§3.2.12). - Only the events have use cases (
ReplyToInvitation,CancelEvent…): for the other components, set the method and the properties of the message yourself.
With a Calendar, use Calendar::setMethod(): the rules are checked for each event. Only PUBLISH accepts events with different UIDs; with the other methods, several events must be a recurring event and its modified occurrences, e.g. to invite to a weekly meeting with one moved occurrence. DTSTART is always required, even for REPLY and CANCEL.
Replying, cancelling, rescheduling
The use cases of Ical\Application\Itip build the iTIP message from an existing event, without modifying it, and return an IcsFile. They take an ExportCalendar and any EventInterface, e.g. an Ical:
use Ical\Application\Itip\CancelEvent;
use Ical\Application\Itip\ReplyToInvitation;
use Ical\Application\Itip\RescheduleOccurrence;
use Ical\Application\Itip\UninviteAttendees;
use Ical\Enum\ParticipationStatusEnum;
// An attendee accepts: METHOD:REPLY, this attendee only with PARTSTAT=ACCEPTED, same UID / SEQUENCE, no alarm
$file = new ReplyToInvitation($exportCalendar)->reply($meeting, 'alice@example.com', ParticipationStatusEnum::ACCEPTED);
// The organizer cancels the event, or one occurrence: METHOD:CANCEL, STATUS:CANCELLED, SEQUENCE + 1
$file = new CancelEvent($exportCalendar)->cancel($meeting);
$file = new CancelEvent($exportCalendar)->cancel($meeting, new \DateTimeImmutable('2025-01-13 09:00:00', $utc));
// The organizer removes attendees: METHOD:CANCEL for them only, without STATUS, SEQUENCE + 1
$file = new UninviteAttendees($exportCalendar)->uninvite($meeting, 'cancellation', 'bob@example.com');
// The organizer moves one occurrence: METHOD:REQUEST with the recurring event and the moved occurrence
// (RECURRENCE-ID, SEQUENCE + 1), keeping the length of the event when no end is given
$file = new RescheduleOccurrence($exportCalendar)->reschedule(
$meeting,
new \DateTimeImmutable('2025-01-13 09:00:00', $utc), // original start of the occurrence
new \DateTimeImmutable('2025-01-14 14:00:00', $utc), // new start
);
- The
SEQUENCEincrease is part of the message only: store it on your event (setSequence()) for the next messages. reply()anduninvite()reject an email that is not an attendee of the event (compared without case).- The messages are checked like any other one (see the table above): e.g.
cancel()throws when the event has no attendee. - To publish events (
PUBLISH) or send an invitation (REQUEST), set the method on the event or the calendar, then callgetICAL()ortoIcsFile().
Output
Text encoding
- Text values (
SUMMARY,DESCRIPTION,LOCATION,PRODID,TZNAME,CATEGORIES,RESOURCES,COMMENT,CONTACT, TEXTX-properties), in every component, are escaped according to RFC 5545 §3.3.11:\becomes\\,;becomes\;,,becomes\,, and line breaks become\n. Control characters other than tab and line breaks are removed. - All text must be valid UTF-8 (RFC 5545 §3.1.4):
getICAL()throws, naming the property, when it is not (e.g. a Latin-1 string). - Lines longer than 75 octets are folded (RFC 5545 §3.1) without splitting multibyte UTF-8 characters.
Downloading the file (HTTP headers)
addHeader() (on Ical and Calendar) sends two headers with PHP's header(), before the output of getICAL():
$ical->setFilename('Réunion équipe')->addHeader();
echo $ical->getICAL();
// Content-Type: text/calendar; charset=utf-8
// Content-Disposition: attachment; filename="R_union _quipe.ics"; filename*=UTF-8''R%C3%A9union%20%C3%A9quipe.ics
With a framework, get the file as an Ical\Application\IcsFile (content, method, filename) and its headers from Ical\Infrastructure\HttpHeaders:
use Ical\Infrastructure\HttpHeaders;
$file = $ical->toIcsFile(); // or $calendar->toIcsFile(), IcalGenerator::generateFile($ical)
return new Response($file->content, 200, HttpHeaders::forFile($file));
// ['Content-Type' => 'text/calendar; charset=utf-8', 'Content-Disposition' => 'attachment; filename="R_union _quipe.ics"; …']
- The
Content-Typecarries the iTIP method when there is one (; method=REQUEST, RFC 6047 §2.4). - The filename gets the
.icsextension and is quoted, with"and\escaped (RFC 6266); it iscalendar.icswhen empty. - Control characters, including line breaks, are removed, so a filename cannot inject another header.
- A non-ASCII filename is also sent as
filename*(RFC 5987), with an ASCII fallback infilename(each run of non-ASCII characters replaced by_). addHeader($send)passes each header line to the$sendclosure instead ofheader(), e.g. in tests.
Error handling
All errors are thrown as Ical\Exceptions\IcalendarException. The message is prefixed by the method that rejected the value, e.g. Ical\Domain\ValueObject\Sequence::__construct --> Sequence must be a non-negative integer !.
- When a value is set, each setter or value object validates its own value: email, timezone, sequence, priority, percent complete, position (
GEO), recurrence rule, duration, period, alarm and trigger, attachment, relation, URL,VTIMEZONEoffsets and dates, last day of an all-day event, duplicate attendee, calendar scale,X-property name and parameters. - When the file is generated (
getICAL(),toIcsFile(),IcalGenerator), the rules between several properties or events are checked, since the setters can be called in any order:- the dates (
DTSTARTrequired, end after start, not both an end and a duration, all-day consistency); - the recurrence (
UNTILmatching the start date,EXDATEonly in a recurring event, noRDATEperiod in an all-day event, modified occurrences); - the alarms related to the end;
- the to-dos, journal entries and free/busy time;
- the iTIP method rules;
- the calendar (at least one component, unique UIDs, one
VTIMEZONEperTZID, iTIP method only with events); - valid UTF-8 text.
- the dates (
RFC 5545 compliance
Every file generated by the library complies with RFC 5545. The library never writes a file that breaks the standard: when a value or a combination of properties is not allowed, it throws an IcalendarException instead of producing an invalid file (see Error handling).
- Format: CRLF line endings, lines folded at 75 octets without splitting UTF-8 characters (§3.1), text escaping (§3.3.11), parameter encoding (§3.2, RFC 6868), valid UTF-8 only (§3.1.4), control characters removed.
- Required properties:
PRODID(in the recommended-//Organisation//Product//LANGformat withsetProdId(), §3.7.3) andVERSION:2.0in every file (§3.7),UIDandDTSTAMPin UTC in every component,DTSTARTin every event (§3.6.1), and the properties each component requires, such asDTSTARTbeforeDURATIONin a to-do (§3.6.2). - Values: dates in UTC, local time with a
TZIDonly when itsVTIMEZONEis defined (§3.2.19) or floating time (§3.3.5),DATEvalues for all-day events with an exclusiveDTENDor aDURATIONin days or weeks (§3.6.1), positiveDURATION,RRULEchecked part by part against itsFREQ(§3.3.10),CALSCALE:GREGORIANonly (§3.7.1),PRIORITYfrom 0 to 9 (§3.8.1.9),GEOwithin the latitude and longitude ranges (§3.8.1.6),X-property names (§3.8.8.2). - Rules between properties:
DTENDafterDTSTART,DTENDandDURATIONmutually exclusive, the same value type forDTSTART,DTEND,UNTIL,EXDATE,RDATEandRECURRENCE-ID,DTEND,DUE,RECURRENCE-IDandUNTILin floating time if and only ifDTSTARTis (§3.8.2.2, §3.8.2.3, §3.8.4.4, §3.3.10), noRDATEperiod in an all-day event,VALARMwith the properties itsACTIONrequires andREPEATandDURATIONtogether, a trigger related to the end only with an end,DUEafterDTSTARTand of the same value type,DURATIONof a to-do only with aDTSTART,RRULEof a to-do or a journal entry only with aDTSTART, unique UIDs except for the occurrences of a recurring event. - iTIP (RFC 5546): the rules of
PUBLISH,REQUEST,REPLYandCANCELare checked forVEVENT,VTODO,VJOURNALandVFREEBUSY, one type of component per message;REQUEST-STATUSonly in aREPLY.
The reference files in tests/stubs/ (an invitation to a recurring event with a modified occurrence, a reply to one occurrence, a cancellation, a published calendar mixing local time, EXDATE, RECURRENCE-ID and an all-day recurring event, a manual VTIMEZONE with its TZURL, a VTIMEZONE given as a whole with several periods, LAST-MODIFIED, RDATE and COMMENT, events and a to-do in floating time with an all-day event with a DURATION, an event with RDATE periods, ATTACH, RELATED-TO, X-properties, LANGUAGE, ALTREP, the parameters of ORGANIZER and ATTENDEE (delegation, MEMBER, SENT-BY, DIR) and the three kinds of alarm, to-dos in local time and journal entries with modified occurrences, free/busy time, a to-do assigned and its reply, a request for free/busy time and its reply) were validated with two independent validators, and a test checks that the library still generates them byte for byte:
- the iCalendar.org validator, for RFC 5545;
- iCal4j, for RFC 5545 and the RFC 5546 rules of the
METHODof each file, run bymake validate(in Docker) and by the CI (tools/ical4j-validator).
Neither of them checks every rule (e.g. a DUE before the DTSTART of a to-do, §3.8.2.3): those are checked by the tests of the library. The files added in 12.1 (floating.ics, vtimezone-definition.ics) were validated with iCal4j only. iCal4j rejects several TZNAME in a STANDARD or DAYLIGHT period, which §3.6.5 allows (tzprop: "MAY occur more than once"): the TZNAME by language of a TimezonePeriod are therefore tested by the tests of the library only.
Scope: the library generates VEVENT, VTODO, VJOURNAL and VFREEBUSY components, with VALARM and VTIMEZONE. Not supported: the iTIP methods ADD, REFRESH, COUNTER and DECLINECOUNTER (RFC 5546), IANA-registered properties other than the X- ones, the TIME value type.
Architecture
You do not need this section to generate files with Ical and Calendar; it describes how to replace parts of the library, e.g. in a dependency injection container or in tests.
Layers
| Layer | Contents |
|---|---|
Domain (Ical\Domain, Ical\Enum, Ical\Exceptions) | The entities (Ical\Domain\Entity: Event, EventCalendar, EventRevision, Todo, Journal, FreeBusy, Attendee, Organizer), the interfaces the other layers read them through (Ical\Domain\Interface), the value objects that validate the values (Ical\Domain\ValueObject), the rules of the components (EventSchedule, ComponentSchedule, FreeBusySchedule) and of the iTIP methods (MethodEnum) |
Application (Ical\Application) | The use cases ExportCalendar and Itip\*, and the ports (interfaces) they need |
Infrastructure (Ical\Infrastructure) | The adapters implementing the ports: RFC 5545 serializer, PHP timezone database, system clock, random UIDs, HTTP headers |
Public API (Ical\Ical, Ical\Calendar, Ical\IcalGenerator) | Ical and Calendar extend the domain entities with the export methods (getICAL(), toIcsFile(), addHeader()); IcalGenerator runs the use case with the default adapters |
The ExportCalendar use case
getICAL() and IcalGenerator run Ical\Application\ExportCalendar with the default adapters. To choose the adapters, build it yourself:
use Ical\Application\ExportCalendar;
use Ical\Infrastructure\PhpTimezoneDefinitionProvider;
use Ical\Infrastructure\RandomUidGenerator;
use Ical\Infrastructure\Rfc5545Serializer;
use Ical\Infrastructure\SystemClock;
$exportCalendar = new ExportCalendar(
new Rfc5545Serializer(), // CalendarSerializerInterface: writes the iCalendar text (RFC 5545)
new PhpTimezoneDefinitionProvider(), // TimezoneDefinitionProviderInterface: automatic VTIMEZONE from the PHP timezone database
new SystemClock(), // ClockInterface: DTSTAMP of the events that do not set it
new RandomUidGenerator(), // UidGeneratorInterface: UID of the events without one
);
$ics = $exportCalendar->exportEvent($ical); // same as IcalGenerator::generate($ical)
$ics = $exportCalendar->exportCalendar($calendar); // same as IcalGenerator::generateCalendar($calendar)
$file = $exportCalendar->exportEventFile($ical, 'meeting'); // IcsFile
$file = $exportCalendar->exportCalendarFile($calendar, 'agenda');
The use case gives the events their final UID, checks the recurrence sets and the iTIP method, picks one VTIMEZONE per TZID (the one defined with the setters, or else the automatic one), then passes a CalendarDocument to the serializer. Each port can be replaced by your own implementation, e.g. a serializer for another format.
It reads the components through the interfaces of Ical\Domain\Interface (EventInterface, TodoInterface, JournalInterface, FreeBusyInterface, EventCalendarInterface), so it accepts an Ical / Calendar, the domain entities, or your own implementations.
Clock and UID generator
When an event has no DTSTAMP or no UID, the current time and a random UID are used. Both come from ports that you can replace, e.g. to get a deterministic output in your tests:
Port (Ical\Application) | Default (Ical\Infrastructure) | Used for |
|---|---|---|
ClockInterface — now(): DateTimeImmutable | SystemClock: the system time in UTC | DTSTAMP of the events that do not set it, read once per file (all the events share it) |
UidGeneratorInterface — generate(string $domain): string | RandomUidGenerator: 128 random bits in hexadecimal, @ and the domain | UID of the events without one, or whose UID is empty once normalized; the domain is the organizer's one, or derived from the PRODID |
use Ical\Application\ClockInterface;
use Ical\Application\UidGeneratorInterface;
use Ical\IcalGenerator;
$clock = new class implements ClockInterface {
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable('2025-01-01 08:00:00', new \DateTimeZone('UTC'));
}
};
$uidGenerator = new class implements UidGeneratorInterface {
public function generate(string $domain): string
{
return 'fixed-uid@' . $domain;
}
};
$ics = IcalGenerator::generate($ical, null, $clock, $uidGenerator);
$ics = IcalGenerator::generateCalendar($calendar, $clock, $uidGenerator);
// → DTSTAMP:20250101T080000Z
// → UID:fixed-uid@example.com
ClockInterface::now() has the same signature as the PSR-20 one: a PSR-20 clock can be wrapped in a few lines, without the library depending on psr/clock.
Domain entities and value objects
The domain is split in three namespaces:
| Namespace | Contents |
|---|---|
Ical\Domain\Entity | The entities, which have an identity: an event, a to-do, a journal entry, free/busy time (their UID), a calendar, a participant (its calendar address) |
Ical\Domain\Interface | EventInterface, TodoInterface, JournalInterface, FreeBusyInterface (and their common ComponentInterface, DescribedComponentInterface), EventCalendarInterface: through them the use cases and the adapters read the components; an event may also implement FloatingTimeInterface (floating DTSTART / DTEND) and VtimezoneDefinitionInterface (a VTIMEZONE given as a whole), as Ical, Event and EventRevision do |
Ical\Domain\ValueObject | The value objects, compared by value |
| Entity | Role |
|---|---|
Event | The data of an event, with the same setters as Ical but without the export methods |
EventCalendar | The data of a calendar, with the same setters as Calendar but without the export methods |
Todo, Journal, FreeBusy | A to-do, a journal entry, free/busy time information (setters), extending the abstract Component (and DescribedComponent for the first two) |
EventRevision | A revised copy of any event (withMethod(), withStatus(), withAttendees(), withIncrementedSequence(), withoutAlarm(), asOccurrence()); the iTIP use cases rely on it |
Organizer | Email $email, ?string $name, ?Email $sentBy, ?Url $directory, ?Language $language; Organizer::fromValues() builds it from strings |
Attendee | Email $email + optional name, role, participationStatus, rsvp, calendarUserType, members, delegatedTo, delegatedFrom, sentBy, directory, language; asReply($status); an event cannot have two attendees with the same email |
The values are validated by immutable value objects (final readonly, public properties) that throw from their constructor, so an instance is always valid. The setters keep accepting strings and integers and build them internally; you can also use them directly:
use Ical\Domain\ValueObject\Email;
$email = new Email('foo'); // IcalendarException: "Ical\Domain\ValueObject\Email::__construct --> Invalid email 'foo' !"
Value object (Ical\Domain\ValueObject) | Value | Validation |
|---|---|---|
Email | string $value, Uri $uri | valid email address (FILTER_VALIDATE_EMAIL); $uri is its mailto: URI (RFC 6068, percent-encoded) |
Sequence | int $value | >= 0 |
Priority | int $value | from 0 (undefined) to 9 |
Geo | float $latitude, float $longitude | latitude from -90 to 90, longitude from -180 to 180; Geo::fromCoordinates() accepts two null |
Attachment | ?Uri $uri, ?string $content, ?string $mediaType | Attachment::fromUrl(): absolute URL; Attachment::fromContent(): not empty; media type type/subtype |
Language | string $value | a language tag (RFC 5646) such as fr, en-US |
Relation | string $uid, RelationTypeEnum $type | UID not blank, PARENT by default |
Period | DateTimeImmutable $start, $end, ?Duration $duration | Period::between(): end later than the start; Period::startingAt(): positive duration, the end computed |
Alarm, AlarmTrigger | action, trigger, description, summary, attendees, attachment, repeat, repeatInterval; offset, relatedToEnd or at | the properties required by the action (Alarm::display(), audio(), email()), REPEAT at least 1 with a positive DURATION; a signed duration or a UTC date-time as trigger |
ComponentDate | DateTimeImmutable $value, bool $isDate, ?string $tzid, bool $isFloating | a DATE, a UTC, local (TZID) or floating date-time of a to-do or a journal entry |
PercentComplete | int $value | from 0 to 100 |
FreeBusyTime | Period $period, FreeBusyTypeEnum $type | — |
ComponentRecurrenceSet | string $uid, list<TodoInterface\|JournalInterface> $components | to-dos or journal entries sharing a UID: one type, at most one main component, which recurs, and its occurrences of the same value type |
ComponentSchedule, FreeBusySchedule | DescribedComponentInterface / FreeBusyInterface | the rules between the dates of a to-do, a journal entry or free/busy time, checked on export |
XProperty | string $name, string $value, array<string, string> $parameters | X- name and parameter names (upper-cased); a value that is not TEXT (VALUE parameter) without line break; isText() |
TimezoneId | string $value | timezone known by PHP |
UtcOffset | string $value | ±HHMM[SS], not -0000 |
LocalDateTime | string $value | existing YYYYMMDDTHHMMSS date-time |
Duration | string $value | positive RFC 5545 duration; Duration::fromDateInterval() |
Url | Uri $uri | absolute URI (Uri\Rfc3986\Uri, RFC 3986 §4.3) |
RecurrenceRule | string $value | valid RRULE, its rule parts in $parts; RecurrenceRule::forTimezoneObservance() also requires a UTC UNTIL, RecurrenceRule::forEvent($value, $allDay, $floating) an UNTIL matching the start date (DATE, local or UTC) |
RecurrenceSet | string $uid, list<EventInterface> $events | events sharing a UID: at most one main event and its occurrences (RECURRENCE-ID); RecurrenceSet::fromEvents() groups events by UID |
ItipMessage | MethodEnum $method, list<RecurrenceSet> $recurrenceSets | the rules of the iTIP method (RFC 5546), defined by MethodEnum::violations() |
EventSchedule | EventInterface $event | when the event happens, checked on export: DTSTART required, DTEND after DTSTART, not both DTEND and DURATION, DTEND floating if and only if DTSTART is, an all-day event without date-time and with a DURATION in days or weeks only without last day, RRULE matching the start date, EXDATE only in a recurring event; exposes the $start and $end dates |
TimezoneDefinition, TimezonePeriod | string $tzid, list<TimezonePeriod> $periods, ?Uri $url, ?DateTimeImmutable $lastModified | a VTIMEZONE and its STANDARD / DAYLIGHT periods (onset, offsets, name, rule, RDATE onsets, TZNAME by language, comments; a rule with a UTC UNTIL), given with setVtimezone(), defined with the setters or built from the PHP timezone database |
TimezoneTransition, TimezoneObservance | change of UTC offset, yearly rule of changes | used to generate the automatic VTIMEZONE |
API reference
All setters return the object itself (static), so they can be chained.
Ical
| Topic | Methods |
|---|---|
| Dates | setDateStart(DateTimeInterface $date, bool $normalizeToUTC = true, bool $floating = false), isDateStartFloating(): bool, isDateEndFloating(): bool, setDateEnd(…), setDateStamp(…), setDuration(string $duration) |
| Timezone | setTimezoneICal(string $timezone), setAutoVtimezone(bool $enabled), setIncludeVtimezone(bool $include) |
| Manual VTIMEZONE | setVtimezone(?TimezoneDefinition $definition), setVtimezoneTzid(string $tzid), setVtimezoneTzurl(string $url), setVtimezone{Standard,Daylight}{Tzname,Tzoffsetfrom,Tzoffsetto,Dtstart,Rrule}(string $value) |
| All-day | setAllDay(DateTimeInterface $firstDay, ?DateTimeInterface $lastDay = null), isAllDay(): bool, getAllDayStart(): string, getAllDayEnd(): string |
| Recurrence | setRrule(string $rule), setExdates(DateTimeInterface ...$dates), setRdates(DateTimeInterface ...$dates), setRdatePeriods(Period ...$periods), setRecurrenceId(?DateTimeInterface $originalStart, bool $thisAndFuture = false), isRecurrenceIdThisAndFuture(): bool |
| Identification | setUid(string $uid), setSequence(int $sequence), setProdId(string $organisation, string $product, string $language = 'EN'), setName(string $name), setCalendarType(?CalendarTypeEnum $type) |
| Details | setSummary(string), setDescription(string, ?string $altRep = null), setAddress(string, ?string $altRep = null), setLanguage(?string), setStatus(StatusEnum), setTransparency(?TransparencyEnum), setClassification(?ClassificationEnum), setUrl(string), setCategories(string ...), setResources(string ...), setComments(string ...), setContacts(string ...), setPriority(?int), setGeo(?float, ?float), setAttachments(Attachment ...), setRelatedTo(Relation ...), setCreated(DateTimeInterface), setLastModified(DateTimeInterface) |
| X-properties | addXProperty(string $name, string $value, array $parameters = []), getXProperties(): list<XProperty> |
| Alarms | setAlarm(bool $enabled), setAlarmMinutesBefore(int $minutes), setAlarmRepeat(int $repeatCount, ?int $intervalMinutes = null), addAlarm(Alarm ...$alarms), getAlarms(): list<Alarm>, hasAlarm(): bool |
| Invitations | setOrganizer(string $email, ?string $name = null, ?string $sentBy = null, ?string $directory = null, ?string $language = null), addAttendee(Attendee ...$attendees), getAttendees(): list<Attendee>, setMethod(?MethodEnum $method) |
| Output | getICAL(?string $uid = null): string, toIcsFile(?string $uid = null): IcsFile, setFilename(string $filename), addHeader(?Closure $send = null) |
Most setters have a matching getter (getDateStart(), getRrule(), getExdates(): DateTimeImmutable[], getRecurrenceId(): ?DateTimeImmutable, …), defined by Ical\Domain\Interface\EventInterface.
Calendar
| Method | Description |
|---|---|
addEvent(EventInterface ...$events), getEvents(): list<EventInterface> | The events of the calendar |
addTodo(TodoInterface ...), addJournal(JournalInterface ...), addFreeBusy(FreeBusyInterface ...) | The other components, with getTodos(), getJournals(), getFreeBusies() |
setProdId(string $organisation, string $product, string $language = 'EN') | PRODID of the file; the events' one is ignored |
setMethod(?MethodEnum $method), getMethod(): ?MethodEnum | iTIP method of the file; the events' one is ignored |
addXProperty(string $name, string $value, array $parameters = []) | Non-standard calendar property |
getICAL(): string, toIcsFile(): IcsFile | The file content, or the content with its method and filename |
setFilename(string $filename), addHeader(?Closure $send = null) | Download |
IcalGenerator
Static facade running ExportCalendar with the default adapters; the optional $clock and $uidGenerator replace the system clock and random UIDs.
| Method | Returns |
|---|---|
generate(Ical $ical, ?string $uid = null, ?ClockInterface $clock = null, ?UidGeneratorInterface $uidGenerator = null) | string, same as $ical->getICAL($uid) |
generateCalendar(Calendar $calendar, ?ClockInterface $clock = null, ?UidGeneratorInterface $uidGenerator = null) | string, same as $calendar->getICAL() |
generateFile(…), generateCalendarFile(…) | IcsFile, same arguments |
contentType(?MethodEnum $method), contentDisposition(string $filename) | The full header lines (Content-type: …, Content-Disposition: …) |