johngrogg/ics-parser
A PHP library for parsing iCalendar (.ics) files into associative arrays of calendars and events, with DateTime support, time zone handling, and resolved property arrays. Install via Composer as johngrogg/ics-parser.
Install with the Composer name, not the GitHub name:
composer require johngrogg/ics-parser:^3
Then parse a calendar and dump what you get — the fastest way to learn the shape of the data is to look at a real event object:
use ICal\ICal;
$ical = new ICal(storage_path('app/calendars/team.ics'), [
'defaultTimeZone' => config('app.timezone'),
'defaultSpan' => 1, // years of recurrence generated when an RRULE has no end
]);
foreach ($ical->events() as $event) {
dump($event->summary, $event->dtstart, $event->dtend, $event->uid);
}
First real use case: a public Google/Outlook .ics feed rendered as an "upcoming 7 days" list.
$ics = Http::timeout(10)->get($url)->body();
$ical = new ICal();
$ical->initString($ics); // avoids allow_url_fopen / redirect surprises
$upcoming = collect($ical->events())
->map(fn ($e) => [
'title' => $e->summary ?? '(no title)',
'start' => \Illuminate\Support\Carbon::parse($e->dtstart, config('app.timezone')),
'end' => isset($e->dtend) ? \Illuminate\Support\Carbon::parse($e->dtend, config('app.timezone')) : null,
'where' => $e->location ?? null,
])
->filter(fn ($e) => $e['start']->betweenIncluded(now(), now()->addDays(7)))
->sortBy('start');
Where to look first:
defaultSpan, defaultTimeZone, defaultWeekStart, filterDaysBefore, filterDaysAfter, skipRecurrence, disableCharacterReplacement, httpUserAgent) — that's the entire configuration surface.examples/index.php in the repo — the canonical "render a month view" example and the best reference for the accessor style (getEventCount(), initEvent($i), getSummary(), getStartTime(), …).dump($event) shows you for your own feed. Property sets vary by producer; real feeds differ from the RFC.Wrap it in one service, normalize immediately. Don't sprinkle new ICal(...) through controllers. Parse once, map to your own DTO/Eloquent model, and never let library objects escape:
class IcsFeedReader
{
public function read(string $ics, array $options = []): array
{
$ical = new ICal(null, $options + [
'defaultTimeZone' => config('app.timezone'),
'defaultSpan' => 1,
]);
$ical->initString($ics);
return array_map(function ($e) {
return [
'uid' => $e->uid ?? null,
'title' => $e->summary ?? '',
'start' => isset($e->dtstart) ? Carbon::parse($e->dtstart) : null,
'end' => isset($e->dtend) ? Carbon::parse($e->dtend) : null,
'all_day' => isset($e->dtstart) && !str_contains($e->dtstart, ':'),
'location'=> $e->location ?? null,
'notes' => $e->description ?? null,
'status' => $e->status ?? null,
];
}, $ical->events());
}
}
Fetch with Laravel's HTTP client, parse with initString(). initUrl() relies on allow_url_fopen and gives you very little control (no auth headers, weak timeout/retry semantics). Pull the bytes with Http::withToken(...)->retry(3, 200)->get($url)->body() and hand the string to the parser. This also makes it trivial to cache the raw feed (Cache::remember("ics:{$feed->id}", 600, ...)) and to snapshot it to disk for debugging when a parse goes wrong.
Sync into your own table, keyed on uid. upsert() on (feed_id, uid) for non-recurring feeds; for recurring ones, key on (feed_id, uid, start_at) because expansion produces many instances sharing a UID. Use soft deletes for events that disappear from a feed, but only within the window you actually fetched — otherwise a narrow filterDaysAfter will wipe your whole future calendar.
Prefer the array API for bulk, the accessor API for single-event screens. $ical->events() returns everything at once (simple, memory-bound); getEventCount() + initEvent($i) + getSummary() / getStartTime() gives you a cursor-style read if you only need to render, say, one event detail page.
Run it in a queue, not a request. Parsing a few thousand events with recurrence expansion takes hundreds of milliseconds to seconds. A SyncCalendarFeed job on a schedule, writing to the DB, means your request path only ever reads your own models — and it gives you retries and failure alerting for free.
Test with fixture files, not the network. Commit three or four .ics files (single event, recurring with UNTIL, all-day, weird charset) and use initString(file_get_contents(...)) in PHPUnit/Pest. This is the single highest-value habit with this library, because recurrence edge cases are where it will surprise you.
Laravel-ish glue: bind the reader as a singleton in a service provider if you like, but keep the ICal instance itself short-lived — construct per parse. The parser keeps mutable state on the object ($this->event), so sharing one instance across jobs is a footgun.
composer require u01jmg3/ics-parser fails. The Packagist vendor is johngrogg. This wastes people's time constantly — put it in your README.dtstart / dtend are strings, not DateTime objects. They come through in a Y-m-d H:i:s-ish shape resolved against defaultTimeZone. Always wrap with Carbon::parse() and ->setTimezone(config('app.timezone')) before storing or comparing. Never compare them as strings across feeds.description, location, organizer, status, etc. Accessing $event->location on an event without one will warn under PHP 8. Use ?? null or isset() everywhere — especially in Blade.DTEND on all-day events. All-day events typically have a date-only DTSTART and either a DTEND of the next day (exclusive, per RFC) or nothing at all. Don't render "ends tomorrow" to users; treat the date as a single day.DURATION instead of DTEND. Some producers emit DURATION:PT1H. Check $event->duration if dtend is missing and compute the end yourself.RRULEs at parse time. Three consequences: (1) defaultSpan (default 2 years) bounds open-ended rules — lower it to 1 and you'll cut parse time noticeably; (2) skipRecurrence => true gives you only the master events, which is what you want if you plan to expand with rlanvin/php-rrule yourself; (3) support for exotic rules (BYSETPOS, mixed RDATE/EXDATE, RECURRENCE-ID overrides) is best-effort, not a full RFC 5545 engine. Verify against the specific calendar provider you integrate with.TZID values must be IANA, CLDR, or Windows names; a bespoke TZID (some Exchange exports) falls back to the default. Set defaultTimeZone explicitly and call $ical->setDefaultTimeZone('Europe/London') after construction if you need to override. Floating times (no Z, no TZID) are interpreted in the default zone — decide deliberately whether that's UTC or the user's zone.initUrl() requires allow_url_fopen. Many hardened PHP images disable it. Another reason to download the feed yourself.disableCharacterReplacement => true. Escape summary/description on output regardless — an ICS feed is untrusted input and can carry HTML/script payloads.events() materializes everything, and expanded recurrences multiply that. For big feeds, cap with filterDaysBefore / filterDaysAfter and defaultSpan, or skipRecurrence and expand lazily.\Exception with a message string (bad path, unreadable file, invalid week start). Wrap the parse in try/catch, log $e->getMessage() plus the raw feed body — without the body, debugging a malformed feed is guesswork..ics, reach for eluceo/ical or spatie/icalendar-generator and keep this one for ingestion.ICal to override date/duration formatting is possible but brittle across minor versions — prefer transforming after events() returns.LOCATION line upstream.How can I help you explore Laravel packages today?