Search by

calendar / icsfile

ColonelMoutarde

This simple class generate a .ics file.

11.0.0 2026-10-08 04:57 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 Validator classes, makes the iTIP rules stricter and changes a few outputs.

Table of contents

  1. Getting started
  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. Architecture
  10. 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: PRODID in the -//Organisation//Product//LANG format, VERSION:2.0, UID, DTSTAMP in UTC and DTSTART are always present (§3.6.1, §3.7).
  • Values: dates in UTC, local time with a TZID only when its VTIMEZONE is defined (§3.2.19), DATE values for all-day events with an exclusive DTEND, positive DURATION, RRULE checked part by part against its FREQ (§3.3.10), CALSCALE:GREGORIAN only (§3.7.1).
  • Rules between properties: DTEND after DTSTART, DTEND and DURATION mutually exclusive, the same value type for DTSTART, DTEND, UNTIL, EXDATE, RDATE and RECURRENCE-ID, VALARM with REPEAT and DURATION together, unique UIDs except for the occurrences of a recurring event.
  • iTIP (RFC 5546): the rules of PUBLISH, REQUEST, REPLY and CANCEL are 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) 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.

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');

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(), setDateEnd() or setDuration().

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.
  • RDATE periods (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: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

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. 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… or PTnHnMnS (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.
  • 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.
  • CATEGORIES are written on one line, each one escaped; empty or blank categories are ignored, and setCategories() without argument removes them.
  • CREATED and LAST-MODIFIED are always written in UTC.
  • An empty string (or null for 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() sets REPEAT and the DURATION between repetitions, as RFC 5545 requires both together: 5 minutes when no interval is given, at least 1 minute.
  • setRepeat() and getRepeat() are deprecated since 11.0.0: they have no effect on the generated file, use setAlarmRepeat().
  • The REPLY and CANCEL methods 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 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 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 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.

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

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) 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\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, VTIMEZONE offsets 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:

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

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 objectValueValidation
Emailstring $value, Uri $urivalid email address (FILTER_VALIDATE_EMAIL); $uri is its mailto: URI (RFC 6068, percent-encoded)
OrganizerEmail $email, ?string $name—
AttendeeEmail $email + optional name, role, participationStatus, rsvp, calendarUserType—
Sequenceint $value>= 0
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) an UNTIL matching the start date
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, 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, TimezonePeriodstring $tzid, list<TimezonePeriod> $periodsa VTIMEZONE and its STANDARD / DAYLIGHT periods (onset, offsets, name, rule), 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), setDateEnd(…), setDateStamp(…), setDuration(string $duration)
TimezonesetTimezoneICal(string $timezone), setAutoVtimezone(bool $enabled), setIncludeVtimezone(bool $include)
Manual VTIMEZONEsetVtimezoneTzid(string $tzid), 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), 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), setAddress(string), setStatus(StatusEnum), setTransparency(?TransparencyEnum), setClassification(?ClassificationEnum), setUrl(string), setCategories(string ...), setCreated(DateTimeInterface), setLastModified(DateTimeInterface)
AlarmssetAlarm(bool $enabled), setAlarmMinutesBefore(int $minutes), setAlarmRepeat(int $repeatCount, ?int $intervalMinutes = null), hasAlarm(): bool; deprecated: setRepeat(), getRepeat()
InvitationssetOrganizer(string $email, ?string $name = 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\EventInterface.

Calendar

MethodDescription
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(): ?MethodEnumiTIP method of the file; the events' one is ignored
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: …)