calendar / icsfile
This simple class generate a .ics file.
Requires
- php: >=8.4
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
- 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-09 12:09:04 UTC
README
A dependency-free PHP library that generates iCalendar (.ics) files following RFC 5545, including invitations, replies and cancellations (iTIP, RFC 5546).
Upgrading from 10.x? See UPGRADE.md: 11.0 removes the
Validatorclasses, makes the iTIP rules stricter and changes a few outputs.
Table of contents
- Getting started
- Dates and timezones
- Recurring events
- Event details
- Several events in one calendar
- Invitations (iTIP)
- Output
- Error handling
- Architecture
- API reference
Getting started
Installation
PHP 8.5 or later is required (native Uri\Rfc3986\Uri).
composer require calendar/icsfile
A first event
Each Ical object is one event (VEVENT). Its setters are fluent and validate their value; getICAL() checks the whole event and returns the file content:
<?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
The other setters are described below by topic, and listed in the API reference.
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:
PRODIDin the-//Organisation//Product//LANGformat,VERSION:2.0,UID,DTSTAMPin UTC andDTSTARTare always present (§3.6.1, §3.7). - Values: dates in UTC, local time with a
TZIDonly when itsVTIMEZONEis defined (§3.2.19),DATEvalues for all-day events with an exclusiveDTEND, positiveDURATION,RRULEchecked part by part against itsFREQ(§3.3.10),CALSCALE:GREGORIANonly (§3.7.1). - Rules between properties:
DTENDafterDTSTART,DTENDandDURATIONmutually exclusive, the same value type forDTSTART,DTEND,UNTIL,EXDATE,RDATEandRECURRENCE-ID,VALARMwithREPEATandDURATIONtogether, unique UIDs except for the occurrences of a recurring event. - iTIP (RFC 5546): the rules of
PUBLISH,REQUEST,REPLYandCANCELare checked.
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) were validated with the iCalendar.org validator, and a test checks that the library still generates them byte for byte.
Scope: the library generates VEVENT components (with VALARM and VTIMEZONE); VTODO, VJOURNAL and VFREEBUSY are not generated, and RDATE periods (VALUE=PERIOD) are not supported.
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.
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');
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(),setDateEnd()orsetDuration().
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. RDATEperiods (VALUE=PERIOD) are not supported.
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
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. setCalendarType() accepts only CalendarTypeEnum::GREGORIAN; the other cases are deprecated.
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')
->setCreated(new \DateTimeImmutable('2024-11-01 09:00:00'))
->setLastModified(new \DateTimeImmutable('2024-11-15 18:30:00'));
// DURATION:PT1H30M
// URL:https://example.com/events/42
// CATEGORIES:Meeting,Project\, Alpha
// CLASS:PRIVATE
// 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\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.CATEGORIESare written on one line, each one escaped; empty or blank categories are ignored, andsetCategories()without argument removes them.CREATEDandLAST-MODIFIEDare always written in UTC.- An empty string (or
nullfor the enum setters) removes the property.
Alarms (VALARM)
// 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.setRepeat()andgetRepeat()are deprecated since 11.0.0: they have no effect on the generated file, usesetAlarmRepeat().- The
REPLYandCANCELmethods do not allow alarms.
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 event (RFC 5545 §3.6).
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\Attendee;
use Ical\Domain\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) writes the ORGANIZER, with the optional name as CN parameter; some clients such as Google Calendar require it to accept updates.
- 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):
- 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.
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 |
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) 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\Sequence::__construct --> Sequence must be a non-negative integer !.
- When a value is set, each setter validates its own value: email, timezone, sequence, recurrence rule, duration, URL,
VTIMEZONEoffsets and dates, last day of an all-day event, duplicate attendee, calendar scale. - 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, modified occurrences); - the iTIP method rules;
- the calendar (at least one event, unique UIDs, one
VTIMEZONEperTZID); - valid UTF-8 text.
- the dates (
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 Event and EventCalendar, the value objects that validate the values, the rules of an event (EventSchedule) 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 events through Ical\Domain\EventInterface and Ical\Domain\EventCalendarInterface, so it accepts an Ical / Calendar, the domain entities they extend, 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
Ical\Domain\Event and Ical\Domain\EventCalendar hold the data of an event and a calendar, with the same setters as Ical and Calendar but without the export methods. Ical\Domain\EventRevision builds a revised copy of any event (withMethod(), withStatus(), withAttendees(), withIncrementedSequence(), withoutAlarm(), asOccurrence()); the iTIP use cases rely on it.
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\Email;
$email = new Email('foo'); // IcalendarException: "Ical\Domain\Email::__construct --> Invalid email 'foo' !"
| Value object | Value | Validation |
|---|---|---|
Email | string $value, Uri $uri | valid email address (FILTER_VALIDATE_EMAIL); $uri is its mailto: URI (RFC 6068, percent-encoded) |
Organizer | Email $email, ?string $name | — |
Attendee | Email $email + optional name, role, participationStatus, rsvp, calendarUserType | — |
Sequence | int $value | >= 0 |
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) an UNTIL matching the start date |
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, an all-day event without date-time nor DURATION, RRULE matching the start date, EXDATE only in a recurring event; exposes the $start and $end dates |
TimezoneDefinition, TimezonePeriod | string $tzid, list<TimezonePeriod> $periods | a VTIMEZONE and its STANDARD / DAYLIGHT periods (onset, offsets, name, rule), 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), setDateEnd(…), setDateStamp(…), setDuration(string $duration) |
| Timezone | setTimezoneICal(string $timezone), setAutoVtimezone(bool $enabled), setIncludeVtimezone(bool $include) |
| Manual VTIMEZONE | setVtimezoneTzid(string $tzid), 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), 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), setAddress(string), setStatus(StatusEnum), setTransparency(?TransparencyEnum), setClassification(?ClassificationEnum), setUrl(string), setCategories(string ...), setCreated(DateTimeInterface), setLastModified(DateTimeInterface) |
| Alarms | setAlarm(bool $enabled), setAlarmMinutesBefore(int $minutes), setAlarmRepeat(int $repeatCount, ?int $intervalMinutes = null), hasAlarm(): bool; deprecated: setRepeat(), getRepeat() |
| Invitations | setOrganizer(string $email, ?string $name = 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\EventInterface.
Calendar
| Method | Description |
|---|---|
addEvent(EventInterface ...$events), getEvents(): list<EventInterface> | The events of the calendar |
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 |
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: …) |