A tiny PHP HTTP service that turns JSON event data into valid RFC 5545
iCalendar (.ics) feeds. Slim 4, no iCalendar dependency, the builder is
~150 lines of careful string handling because that's the whole point of the
article behind this repo.
Designed as the thing you put in front of your internal calendar data when
your team keeps generating .ics feeds by string concatenation and keeps
hitting the same RFC 5545 edge cases:
- CRLF line endings (not LF)
- Line folding at 75 octets, not 75 characters
- Text escaping for
\,;,,, and newlines UID,DTSTAMP,DTSTART,DTENDall required on everyVEVENTPRODID+VERSIONrequired on the envelope
| Method | Path | Purpose |
|---|---|---|
| POST | /ical |
JSON body → text/calendar response |
| GET | /ical |
?url=<json-source> → fetch + convert |
| POST | /validate |
iCal text → {valid, errors:[...]} |
| GET | /health |
{status, version} |
| GET | / |
HTML explainer |
{
"calendar": { "name": "Team", "description": "internal", "timezone": "UTC" },
"events": [
{
"uid": "optional-stable-id@example.com",
"title": "Standup",
"start": "2026-04-20T09:00:00Z",
"end": "2026-04-20T09:15:00Z",
"location": "HQ",
"description": "daily",
"url": "https://example.com/meeting",
"recurrence": { "freq": "DAILY", "interval": 1, "count": 10 }
}
]
}title,start,endare required on every event.endmust be strictly greater thanstart(422otherwise).uidis auto-generated if not supplied.recurrence.freqis one ofDAILY,WEEKLY,MONTHLY. Optionalinterval, and exactly one ofcount/until.- Date-times accept ISO 8601 (
2026-04-20T09:00:00Zor no suffix for floating), or the iCal basic form (20260420T090000Z).
It's complicated and most consumers do the right thing with either UTC
(Z-suffixed) or floating times. If you need full VTIMEZONE + TZID
parameter handling, reach for sabre/vobject — this service is deliberately
the 90% case.
docker build -t ical-api .
docker run --rm -p 8000:8000 ical-api
curl http://localhost:8000/healthcurl -sS -X POST http://localhost:8000/ical \
-H "Content-Type: application/json" \
-d '{
"calendar": {"name": "Team"},
"events": [{
"title": "Standup",
"start": "2026-04-20T09:00:00Z",
"end": "2026-04-20T09:15:00Z",
"location": "HQ"
}]
}'Response:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SEN LLC//ical-api 1.0//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:Team
BEGIN:VEVENT
UID:...@ical-api
DTSTAMP:20260415T120000Z
DTSTART:20260420T090000Z
DTEND:20260420T091500Z
SUMMARY:Standup
LOCATION:HQ
END:VEVENT
END:VCALENDAR
(with CRLF line endings — verify with curl ... | xxd | head and you'll see
0d 0a after every line).
curl -sS -X POST http://localhost:8000/validate \
-H "Content-Type: text/plain" \
--data-binary @my-calendar.icsReturns {"valid": true, "errors": []} or a list of structural errors with
line numbers.
composer install
./vendor/bin/phpunit --no-coverageMIT. See LICENSE.