Search by

calendar / icsfile

ColonelMoutarde

This simple class generate a .ics file.

12.1.0 2026-10-31 18:51 UTC

README

SymfonyInsight

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, Interface and ValueObject, removes the deprecated setRepeat() / getRepeat() and non-Gregorian calendar types, and adds to-dos, journal entries, free/busy time and most of RFC 5545.

Table of contents

  1. Quick start
  2. Dates and timezones
  3. Recurring events
  4. Event details
  5. Several events in one calendar
  6. Invitations (iTIP)
  7. Output
  8. Error handling
  9. RFC 5545 compliance
  10. Architecture
  11. 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:00 in Paris is 07:00Z), see local time to keep the TZID.
  • DTSTAMP is the current time unless setDateStamp() is called.

Common tasks

To…UseDetails
Create an all-day eventsetAllDay(new \DateTimeImmutable('2025-12-25')) instead of setDateStart()All-day events
Repeat an eventsetRrule('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 remindersetAlarmMinutesBefore(30)Alarms
Put several events in one filenew Calendar()->addEvent($first, $second), then getICAL() on the calendarSeveral events in one calendar
Send an invitationsetOrganizer('boss@example.com'), addAttendee(new Attendee(new Email('john@example.com'))), setMethod(MethodEnum::REQUEST)Invitations (iTIP)
Reply to or cancel an invitationReplyToInvitation, CancelEvent, RescheduleOccurrenceReplying, 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/busyCalendar::addTodo(), addJournal(), addFreeBusy()To-dos, journal entries and free/busy time
Get a deterministic output in testsIcalGenerator::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) or setDuration() (DURATION, see Description and classification), not both.
  • setDateStamp() sets DTSTAMP, 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
  • DTEND must be floating if and only if DTSTART is (§3.8.2.2), which is checked by getICAL(); their order is compared on the clock times.
  • RDATE, EXDATE and RECURRENCE-ID follow DTSTART: 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 UNTIL of the RRULE is then a local date-time YYYYMMDDTHHMMSS, without Z (§3.3.10).
  • DTSTAMP, CREATED, LAST-MODIFIED and the date-time of an alarm TRIGGER stay in UTC, as RFC 5545 requires.
  • To-dos and journal entries take the same floating: true in setStart() and setDue() (to-dos); DUE must be floating if and only if DTSTART is (§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 or DURATION, RDATE, EXDATE, RRULE UNTIL). An event recurring forever (RRULE without UNTIL, or with COUNT) 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 single STANDARD.
  • No VTIMEZONE is added when no date is written in local time (UTC dates, all-day events, UTC timezone).
  • In a Calendar, one VTIMEZONE is 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: rdates are other onsets (RDATE, in local time like the onset), localizedNames other TZNAME by language tag (TZNAME;LANGUAGE=fr:HNE), comments the COMMENT lines (§3.6.5). The UNTIL of its rule must be a UTC date-time, even for a rule built with new RecurrenceRule().
  • TimezoneDefinition: url is the TZURL, lastModified the LAST-MODIFIED, written in UTC.
  • Its TZID must be the setTimezoneICal() timezone, which is checked by getICAL(); setIncludeVtimezone(false) ignores it, setVtimezone(null) removes it. In a Calendar, two events cannot give different definitions for the same TZID.

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: ±HHMM or ±HHMMSS (hours 00-23, minutes and seconds 00-59), not -0000 (§3.3.14);
  • Dtstart: local date-time YYYYMMDDTHHMMSS, without Z (§3.6.5);
  • Tzname: optional, the TZNAME line is omitted when empty;
  • Rrule: optional, a valid recurrence rule whose UNTIL is a UTC date-time such as 20061029T060000Z.

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 DTEND is 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:30 in America/New_York gives 20251225. The time and setTimezoneICal() are ignored.
  • The last day cannot be before the first day.
  • An all-day event cannot also have setDateStart() or setDateEnd().
  • Without a last day, setDuration() gives the length in days or weeks instead of the DTEND (§3.6.1): setAllDay(new \DateTimeImmutable('2025-08-01'))->setDuration('P2W') gives DTSTART;VALUE=DATE:20250801 and DURATION:P2W. A duration with hours (PT24H, P1DT12H) or a last day given with a duration is rejected by getICAL().

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():

  • FREQ is required and must be the first rule part; every rule part may appear only once;
  • UNTIL and COUNT cannot be combined; COUNT and INTERVAL must be positive integers;
  • numeric values must be in range (e.g. BYMONTH 1 to 12, BYHOUR 0 to 23, BYDAY ordinal ±1 to ±53);
  • rule parts that RFC 5545 forbids for a given FREQ are rejected (e.g. BYWEEKNO without FREQ=YEARLY, BYDAY=1MO with FREQ=WEEKLY, BYSETPOS without another BYxxx part).

Its consistency with the start date is checked by getICAL(), since the start date may be set after the rule:

Start dateUNTIL must beForbidden rule parts
setDateStart() (UTC or local time with TZID)a UTC date-time, e.g. 20251231T235959Z—
setAllDay()a date, e.g. 20251231BYHOUR, 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, or DATE values for an all-day event. A date given in another timezone is converted.
  • The values are sorted and duplicates are removed.
  • An EXDATE must be the start of an occurrence to remove it, and requires an RRULE or an RDATE. RDATE alone 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 RDATE line, their bounds in the same form as the start date (UTC or local time with TZID), 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:20250113 for 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, default 0): 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//LANG required 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… or PTnHnMnS (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.
  • URL must be an absolute URI conforming to RFC 3986 (parsed with Uri\Rfc3986\Uri, so it must have a scheme and non-ASCII characters must be percent-encoded); getUrl() returns the Uri\Rfc3986\Uri, or null when no URL is set, and it is written as given.
  • setDescription($text, $altRep) and setAddress($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 as fr or en-US), written on SUMMARY, DESCRIPTION, LOCATION, COMMENT, CONTACT, CATEGORIES and RESOURCES; null removes it.
  • CATEGORIES are written on one line, each one escaped; empty or blank categories are ignored, and setCategories() without argument removes them.
  • RESOURCES (§3.8.1.10) follow the same rules as CATEGORIES; COMMENT (§3.8.1.4) and CONTACT (§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 (otherwise IcalendarException); null removes 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 single null coordinate throws an IcalendarException. getGeo() returns an Ical\Domain\ValueObject\Geo (latitude, longitude), or null.
  • CREATED and LAST-MODIFIED are always written in UTC.
  • An empty string (or null for the enum setters) removes the property, with two exceptions: SUMMARY is always written, empty when not set, because RFC 5546 requires it in PUBLISH and REQUEST; setTransparency(null) restores the default TRANSP: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 its RELTYPE: PARENT (default), CHILD or SIBLING.
  • 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() sets REPEAT and the DURATION between 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
FactoryWrites
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
TriggerWrites
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 REPLY and CANCEL methods 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.1 x-name), and the parameter names must be made of letters, digits or dashes; both are upper-cased. Otherwise addXProperty() throws an IcalendarException.
  • The value is TEXT by default and escaped like DESCRIPTION. With a VALUE parameter 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 as Ical\Domain\ValueObject\XProperty (name, value, parameters).
  • The calendar properties are written by Calendar only: 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 PRODID and METHOD of the calendar are used, those of the events are ignored. Without setProdId(), the PRODID is -//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 VTIMEZONE used by several events is written once, before the events; two different VTIMEZONE for the same TZID are 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 threesetUid(), setDateStamp(), setLanguage(?string), setOrganizer($email, ?$name, ?$sentBy, ?$directory, ?$language), addAttendee(Attendee ...), setComments(string ...), setUrl(), addXProperty()
Todo and JournalsetStart(?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()
TodosetDue(?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 ...)
JournalsetStatus(?JournalStatusEnum): DRAFT, FINAL, CANCELLED
FreeBusysetStart(?DateTimeInterface), setEnd(?DateTimeInterface), setContact(), addBusyTime(Period, FreeBusyTypeEnum = BUSY)
  • setStart() and setDue() write a UTC date-time by default; with normalizeToUTC: false, the local time of the timezone of the given date with TZID (a named timezone such as Europe/Paris, not an offset), and its VTIMEZONE is always added from the PHP timezone database, covering the dates of the component (one per TZID, shared with the events; a VTIMEZONE defined with the setters of an event is used for its TZID); with floating: true, the clock time of the given date without Z nor TZID (floating time); with allDay: true, a DATE (VALUE=DATE), only the calendar date being used. RDATE and EXDATE follow the form of DTSTART.
  • The other date-times (DTSTAMP, COMPLETED, CREATED, LAST-MODIFIED) are written in UTC, as RFC 5545 requires, and so are the dates of a VFREEBUSY (§3.6.4). Without setDateStamp(), DTSTAMP is 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 by getICAL():
    • to-do: DUE and DURATION not both, DURATION only with a DTSTART (in days or weeks with a DATE), DUE of the value type of DTSTART, floating if and only if DTSTART is, and later; a trigger related to the start requires a DTSTART, one related to the end a DUE or a DTSTART and a DURATION (§3.8.6.3);
    • to-do and journal entry: an RRULE, RDATE or EXDATE requires a DTSTART, the UNTIL of the RRULE matching it, and an EXDATE requires an RRULE or an RDATE; RDATE and EXDATE are written in the form of DTSTART (DATE or UTC), sorted and without duplicates;
    • free/busy: DTEND later than DTSTART.
  • FREEBUSY lines are grouped by FBTYPE (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 their DTSTART (which they require). At most one of them has no RECURRENCE-ID, it must recur, and all of them are of the same type and the same value type (DATE or 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 null parameter 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 of Email, written as quoted mailto: URIs; sentBy (SENT-BY), directory (DIR, an Ical\Domain\ValueObject\Url) and language (LANGUAGE, an Ical\Domain\ValueObject\Language) are optional;
  • a reply (ReplyToInvitation) keeps these parameters, e.g. DELEGATED-TO with PARTSTAT=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():

MethodUseRules
PUBLISHShare an event, no reply expectedORGANIZER required, no ATTENDEE
REQUESTInvite, 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
REPLYAnswer of an attendee (same UID as the request)ORGANIZER required, exactly one ATTENDEE with a PARTSTAT, no alarm, all events share the same UID
CANCELCancel the event (STATUS:CANCELLED) or remove the listed attendees (no STATUS), same UID, higher SEQUENCEORGANIZER 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:

ComponentPUBLISHREQUESTREPLYCANCEL
VTODOORGANIZER, no ATTENDEEORGANIZER, at least one ATTENDEE, STATUS not CANCELLEDORGANIZER, exactly one ATTENDEE with a PARTSTAT, no alarmORGANIZER, at least one ATTENDEE, STATUS absent or CANCELLED, no alarm
VJOURNALORGANIZER, no ATTENDEEnot definednot definedORGANIZER, STATUS absent or CANCELLED
VFREEBUSYORGANIZER, DTSTART, DTEND, FREEBUSY, no ATTENDEEORGANIZER, DTSTART, DTEND, at least one ATTENDEE, no FREEBUSYORGANIZER, DTSTART, DTEND, exactly one ATTENDEEnot defined
  • REQUEST-STATUS (setRequestStatuses(new RequestStatus('2.0', 'Success')), §3.8.8.3) is only allowed in a REPLY, for every type of component.
  • The participation statuses COMPLETED and IN-PROCESS (ParticipationStatusEnum) are for to-dos only; a journal entry allows only NEEDS-ACTION, ACCEPTED and DECLINED (§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 SEQUENCE increase is part of the message only: store it on your event (setSequence()) for the next messages.
  • reply() and uninvite() 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 call getICAL() or toIcsFile().

Output

Text encoding

  • Text values (SUMMARY, DESCRIPTION, LOCATION, PRODID, TZNAME, CATEGORIES, RESOURCES, COMMENT, CONTACT, TEXT X- 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-Type carries the iTIP method when there is one (; method=REQUEST, RFC 6047 §2.4).
  • The filename gets the .ics extension and is quoted, with " and \ escaped (RFC 6266); it is calendar.ics when 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 in filename (each run of non-ASCII characters replaced by _).
  • addHeader($send) passes each header line to the $send closure instead of header(), 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, VTIMEZONE offsets 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:

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//LANG format with setProdId(), §3.7.3) and VERSION:2.0 in every file (§3.7), UID and DTSTAMP in UTC in every component, DTSTART in every event (§3.6.1), and the properties each component requires, such as DTSTART before DURATION in a to-do (§3.6.2).
  • Values: dates in UTC, local time with a TZID only when its VTIMEZONE is defined (§3.2.19) or floating time (§3.3.5), DATE values for all-day events with an exclusive DTEND or a DURATION in days or weeks (§3.6.1), positive DURATION, RRULE checked part by part against its FREQ (§3.3.10), CALSCALE:GREGORIAN only (§3.7.1), PRIORITY from 0 to 9 (§3.8.1.9), GEO within the latitude and longitude ranges (§3.8.1.6), X- property names (§3.8.8.2).
  • Rules between properties: DTEND after DTSTART, DTEND and DURATION mutually exclusive, the same value type for DTSTART, DTEND, UNTIL, EXDATE, RDATE and RECURRENCE-ID, DTEND, DUE, RECURRENCE-ID and UNTIL in floating time if and only if DTSTART is (§3.8.2.2, §3.8.2.3, §3.8.4.4, §3.3.10), no RDATE period in an all-day event, VALARM with the properties its ACTION requires and REPEAT and DURATION together, a trigger related to the end only with an end, DUE after DTSTART and of the same value type, DURATION of a to-do only with a DTSTART, RRULE of a to-do or a journal entry only with a DTSTART, unique UIDs except for the occurrences of a recurring event.
  • iTIP (RFC 5546): the rules of PUBLISH, REQUEST, REPLY and CANCEL are checked for VEVENT, VTODO, VJOURNAL and VFREEBUSY, one type of component per message; REQUEST-STATUS only in a REPLY.

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:

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

LayerContents
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(): DateTimeImmutableSystemClock: the system time in UTCDTSTAMP of the events that do not set it, read once per file (all the events share it)
UidGeneratorInterface — generate(string $domain): stringRandomUidGenerator: 128 random bits in hexadecimal, @ and the domainUID 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:

NamespaceContents
Ical\Domain\EntityThe 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\InterfaceEventInterface, 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\ValueObjectThe value objects, compared by value
EntityRole
EventThe data of an event, with the same setters as Ical but without the export methods
EventCalendarThe data of a calendar, with the same setters as Calendar but without the export methods
Todo, Journal, FreeBusyA to-do, a journal entry, free/busy time information (setters), extending the abstract Component (and DescribedComponent for the first two)
EventRevisionA revised copy of any event (withMethod(), withStatus(), withAttendees(), withIncrementedSequence(), withoutAlarm(), asOccurrence()); the iTIP use cases rely on it
OrganizerEmail $email, ?string $name, ?Email $sentBy, ?Url $directory, ?Language $language; Organizer::fromValues() builds it from strings
AttendeeEmail $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)ValueValidation
Emailstring $value, Uri $urivalid email address (FILTER_VALIDATE_EMAIL); $uri is its mailto: URI (RFC 6068, percent-encoded)
Sequenceint $value>= 0
Priorityint $valuefrom 0 (undefined) to 9
Geofloat $latitude, float $longitudelatitude from -90 to 90, longitude from -180 to 180; Geo::fromCoordinates() accepts two null
Attachment?Uri $uri, ?string $content, ?string $mediaTypeAttachment::fromUrl(): absolute URL; Attachment::fromContent(): not empty; media type type/subtype
Languagestring $valuea language tag (RFC 5646) such as fr, en-US
Relationstring $uid, RelationTypeEnum $typeUID not blank, PARENT by default
PeriodDateTimeImmutable $start, $end, ?Duration $durationPeriod::between(): end later than the start; Period::startingAt(): positive duration, the end computed
Alarm, AlarmTriggeraction, trigger, description, summary, attendees, attachment, repeat, repeatInterval; offset, relatedToEnd or atthe 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
ComponentDateDateTimeImmutable $value, bool $isDate, ?string $tzid, bool $isFloatinga DATE, a UTC, local (TZID) or floating date-time of a to-do or a journal entry
PercentCompleteint $valuefrom 0 to 100
FreeBusyTimePeriod $period, FreeBusyTypeEnum $type—
ComponentRecurrenceSetstring $uid, list<TodoInterface\|JournalInterface> $componentsto-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, FreeBusyScheduleDescribedComponentInterface / FreeBusyInterfacethe rules between the dates of a to-do, a journal entry or free/busy time, checked on export
XPropertystring $name, string $value, array<string, string> $parametersX- name and parameter names (upper-cased); a value that is not TEXT (VALUE parameter) without line break; isText()
TimezoneIdstring $valuetimezone known by PHP
UtcOffsetstring $value±HHMM[SS], not -0000
LocalDateTimestring $valueexisting YYYYMMDDTHHMMSS date-time
Durationstring $valuepositive RFC 5545 duration; Duration::fromDateInterval()
UrlUri $uriabsolute URI (Uri\Rfc3986\Uri, RFC 3986 §4.3)
RecurrenceRulestring $valuevalid 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)
RecurrenceSetstring $uid, list<EventInterface> $eventsevents sharing a UID: at most one main event and its occurrences (RECURRENCE-ID); RecurrenceSet::fromEvents() groups events by UID
ItipMessageMethodEnum $method, list<RecurrenceSet> $recurrenceSetsthe rules of the iTIP method (RFC 5546), defined by MethodEnum::violations()
EventScheduleEventInterface $eventwhen 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, TimezonePeriodstring $tzid, list<TimezonePeriod> $periods, ?Uri $url, ?DateTimeImmutable $lastModifieda 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, TimezoneObservancechange of UTC offset, yearly rule of changesused to generate the automatic VTIMEZONE

API reference

All setters return the object itself (static), so they can be chained.

Ical

TopicMethods
DatessetDateStart(DateTimeInterface $date, bool $normalizeToUTC = true, bool $floating = false), isDateStartFloating(): bool, isDateEndFloating(): bool, setDateEnd(…), setDateStamp(…), setDuration(string $duration)
TimezonesetTimezoneICal(string $timezone), setAutoVtimezone(bool $enabled), setIncludeVtimezone(bool $include)
Manual VTIMEZONEsetVtimezone(?TimezoneDefinition $definition), setVtimezoneTzid(string $tzid), setVtimezoneTzurl(string $url), setVtimezone{Standard,Daylight}{Tzname,Tzoffsetfrom,Tzoffsetto,Dtstart,Rrule}(string $value)
All-daysetAllDay(DateTimeInterface $firstDay, ?DateTimeInterface $lastDay = null), isAllDay(): bool, getAllDayStart(): string, getAllDayEnd(): string
RecurrencesetRrule(string $rule), setExdates(DateTimeInterface ...$dates), setRdates(DateTimeInterface ...$dates), setRdatePeriods(Period ...$periods), setRecurrenceId(?DateTimeInterface $originalStart, bool $thisAndFuture = false), isRecurrenceIdThisAndFuture(): bool
IdentificationsetUid(string $uid), setSequence(int $sequence), setProdId(string $organisation, string $product, string $language = 'EN'), setName(string $name), setCalendarType(?CalendarTypeEnum $type)
DetailssetSummary(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-propertiesaddXProperty(string $name, string $value, array $parameters = []), getXProperties(): list<XProperty>
AlarmssetAlarm(bool $enabled), setAlarmMinutesBefore(int $minutes), setAlarmRepeat(int $repeatCount, ?int $intervalMinutes = null), addAlarm(Alarm ...$alarms), getAlarms(): list<Alarm>, hasAlarm(): bool
InvitationssetOrganizer(string $email, ?string $name = null, ?string $sentBy = null, ?string $directory = null, ?string $language = null), addAttendee(Attendee ...$attendees), getAttendees(): list<Attendee>, setMethod(?MethodEnum $method)
OutputgetICAL(?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

MethodDescription
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(): ?MethodEnumiTIP method of the file; the events' one is ignored
addXProperty(string $name, string $value, array $parameters = [])Non-standard calendar property
getICAL(): string, toIcsFile(): IcsFileThe 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.

MethodReturns
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: …)