sabre/vobject
sabre/vobject is a PHP library for parsing and manipulating iCalendar (RFC 5545) and vCard (RFC 6350) objects, with a complete, easy-to-use API. Install via Composer with sabre/vobject ^4.0; requires PHP 5.5+.
Pull the v4 line in (v4.5.x runs on PHP 7.2+ and is CI-tested against 8.2/8.3/8.4; v3.x only if you're stuck on PHP 5.x):
composer require sabre/vobject "^4.5"
There's no service provider, no config file, no facade. It's a plain library — everything is static entry points or plain new. The four classes you'll live in:
Sabre\VObject\Reader::read($data) — parse .ics/.vcf strings into a component tree.Sabre\VObject\Writer::write($component) — serialize back to a MIME-dir (BEGIN:VCALENDAR...) string.Sabre\VObject\Recur\EventIterator — expand RRULE/RDATE/EXDATE into concrete occurrences.Sabre\VObject\ITip\Broker — generate REPLY/REQUEST/CANCEL scheduling messages.Read the docs in this order, they match how you'll actually hit the library: Working with vCards first (small, self-contained), then Working with iCalendar, then the recurrence and iTip pages.
First use case — parse an .ics uploaded by a user and pull out the event fields:
use Sabre\VObject\Reader;
$calendar = Reader::read(file_get_contents($path));
$event = $calendar->VEVENT; // first VEVENT
$summary = (string) $event->SUMMARY; // cast to string, not ->getValue()
$start = $event->DTSTART->getDateTime(); // DateTimeImmutable, TZ-aware
That's the 20-minute win: no hand-rolled RFC 5545 line-folding, no escaping bugs, no charset sniffing. The next 20 minutes go to generating a valid calendar (new VCalendar() → add('VEVENT') → add('UID') → Writer::write) and to recurrence, which is where the real complexity lives.
Reading — read() vs readForValidation(). Reader::read() is forgiving by default: bad properties are skipped, unknown components ignored. Use Reader::read($data, Reader::OPTION_FORGIVING) explicitly when dealing with Outlook exports, and readForValidation() when you want the parser to be strict and throw — that's the one you use in a "validate this upload" endpoint.
use Sabre\VObject\Reader;
use Sabre\VObject\Component\VCalendar;
$vobj = Reader::read($raw, Reader::OPTION_FORGIVING);
$messages = $vobj->validate(VCalendar::REPAIR); // returns Warning[] and fixes what it can
validate() returns an array of Sabre\VObject\Message objects and, with REPAIR, mutates the tree (adds missing UID, DTSTAMP, dedupes single-occurrence properties, etc.). This is the cheapest way to accept dirty user input.
Building components. Don't hand-build property arrays. Use the magic add():
$cal = new VCalendar();
$cal->add('PRODID', '-//MyApp//EN');
$event = $cal->add('VEVENT', [
'UID' => bin2hex(random_bytes(16)),
'DTSTAMP' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
'DTSTART' => new DateTimeImmutable('2025-03-01 09:00', new DateTimeZone('Europe/Amsterdam')),
'SUMMARY' => 'Kickoff',
]);
$event->add('ATTENDEE', 'mailto:[email protected]', ['ROLE' => 'REQ-PARTICIPANT']);
DTSTAMP must be UTC — the library will serialize it incorrectly (and 4.2.0 fixed a bug exactly here) if you pass a local DateTime.
Recurrence. This is the reason people adopt the package. EventIterator yields concrete VEvent instances:
use Sabre\VObject\Recur\EventIterator;
$it = new EventIterator($calendar, 'event-uid-or-null');
foreach ($it as $occurrence) {
$start = $occurrence->DTSTART->getDateTime();
// ... guard against infinite loops
if ($start > $horizon) { break; }
}
Two practical variants:
$event->getRecurrenceIterator() if you already have a specific VEVENT in hand.$calendar->expand($start, $end, $timezone) when you need a materialized calendar for a window (e.g., render a month view). This mutates/clones the tree, so do it on a copy.Always bound iteration by a horizon. Historical bugs (yearly BYDAY with BYDAY=SA,SO, BYYEARDAY only, endless nextDaily) were fixed in 4.1.4/4.5.0, but a bounded loop is still the defensive habit.
Timezones. $property->getDateTime() returns a DateTimeImmutable with the TZID resolved. For VTIMEZONE-bearing calendars the embedded definition is used, not PHP's. If your users are on floating time (DTSTART:20250301T090000 with no TZID/Z), convert explicitly:
$event->DTSTART['VALUE'] = 'DATE-TIME';
$event->DTSTART->setDateTime(new DateTimeImmutable('2025-03-01 09:00', new DateTimeZone('UTC')));
Sabre\VObject\TimeZoneUtil::getTimeZone($tzid) is the resolver — useful when you need to map an Exchange-style TZID ("W. Europe Standard Time") to a real one.
iTip scheduling. If you're doing invites, use Broker and do not hand-craft METHOD:REQUEST:
use Sabre\VObject\ITip\Broker;
$broker = new Broker();
$messages = $broker->parseEvent($newEvent, $oldEvent, $organizer, $attendees);
foreach ($messages as $msg) {
Mail::raw($msg->message->serialize(), fn ($m) => $m->to(...));
}
parseEvent() returns ITip\Message objects whose ->message is a ready-to-send VCALENDAR with correct METHOD, ORGANIZER, per-attendee ATTENDEE, and EXDATE when someone is removed from a recurring event (4.5.8). Use $broker->processMessage() for the reverse direction — parsing an inbound REPLY and applying the PARTSTAT to your stored copy.
Free/busy. Sabre\VObject\FreeBusyGenerator takes a set of calendars plus a time window and produces a VFREEBUSY — the correct tool for "find a slot" features; don't iterate events and merge intervals yourself.
Laravel integration shape. Keep it thin: a CalendarService with parse(UploadedFile): VCalendar, expand(VCalendar, CarbonImmutable $from, CarbonImmutable $to): VCalendar, and build(Request): VCalendar. Wrap it behind an interface so tests can fake it. Run Reader::read() + validate() + expand() inside a queued job for uploaded .ics — a 2000-event calendar with recurrence expansion is CPU-bound and doesn't belong in the request cycle. Store the raw .ics on disk/object storage as the source of truth and only persist expanded occurrences to your DB, so you can re-expand when the library is upgraded.
JSON output. The package ships VObject\Parser\Json for jCard/jCal (RFC 7095/7265). If your frontend wants JSON, serialize the component and use the JSON parser rather than writing a bespoke mapper.
->getValue() blindly. (string) $event->SUMMARY and $event->SUMMARY->getValue() differ for structured values (N, ADR, GEO). Use select('N') and walk the sub-properties for those.$event->summary will not find SUMMARY. select('SUMMARY') returns an array; magic access returns the first.getDateTime() on a DATE value throws. Check $prop['VALUE'] === 'DATE' (or $prop->hasTime()) before treating it as a timestamp. All-day events are the #1 source of "why is my event off by a day".EventIterator yields clones. Mutating the yielded VEvent does not change the parent calendar. To change a single occurrence, you must split it into its own VEVENT with a RECURRENCE-ID — there's no one-liner for this.FindFromTimezoneMap::find() returns null instead of throwing for unknown timezones. If you catch an exception there, your code is now dead — check for null. TimeZoneUtil::getTimeZone() still throws InvalidArgumentException on truly unparseable input, so wrap Exchange-style TZIDs in a try/catch.RRULE throws InvalidDataException (since 4.5.6). Catch it per-event during bulk import rather than letting one bad rule kill a 500-contact migration.ParseException on null input (since 4.5.5). Reader::read($request->file?->get()) — guard the null yourself or you'll get an exception before your validation layer runs.validate() with REPAIR mutates. Clone with clone $vobj first if you want the original for a "before/after" diff or for audit logging.RRULE over a year is 3.6M iterations. Cache expanded occurrences, and cap the window (e.g., 18 months) at the API boundary.ITip\Broker needs a complete event. Missing ORGANIZER, missing ATTENDEE mailto: prefix, or an ATTENDEE without PARTSTAT produces malformed messages. Validate before passing in. Also note 4.5.9 fixed null handling for "old calendar/event" — before that, creating a brand-new event as organizer ($oldEvent = null) blew up. On <4.5.9, pass an empty VCalendar instead of null to parseEvent().SCHEDULE-AGENT=CLIENT on ATTENDEE/ORGANIZER tells servers not to send invitations. If your emails are going out twice, or not at all, check this parameter first.TEL in 3.0 has a VALUE=PHONE-NUMBER type quirk (4.2.2 added it), and BASE64 photo encoding in 3.0 had a repair bug fixed in 4.2.0 — if you're importing Outlook contacts, test encoding first.getByTypes() on VCard (new in 4.6.0) fetches a property by its TYPE list rather than position — cleaner than select('TEL')[0] when you want the work phone.Sabre\VObject\Component\VCalendar and register via $vobj->add($component) or by using the Document::$defaultPlugins/property mapper (Sabre\VObject\Property\ICalendar). Also Sabre\VObject\Property::$defaults for custom value types. Rarely needed, but it's the hook when you must support a vendor extension like X-WR-CALNAME.Reader::read() in forgiving mode swallows bad lines. Switch to readForValidation(), or set Sabre\VObject\Parser\MimeDir directly and inspect. Each parsed Property now carries lineIndex and lineString (4.5.5) — invaluable for pointing a user at the exact bad line in their upload.How can I help you explore Laravel packages today?