laravel-cirrux-calendar maintained by cdsmths
Laravel Cirrux Calendar

A Laravel package for the calendar API of Cirrux: log in, read your calendars and create or delete events. Cached availability and booking are in, so people outside your company can book a demo straight into a Cirrux calendar.
The package only gives you methods. It ships no routes and no UI: the booking page, forms and emails belong to your app.
[!WARNING] Beta and unofficial. Cirrux has not documented its calendar endpoints publicly yet. What this package uses comes from the source code of the open source Cirrux CLI, and it can change without notice. This package is not made by or affiliated with Cirrux.
Releases stay on 0.x until the endpoints are documented, so a minor release may contain breaking changes. Pin a minor version (
^0.1) and read the changelog before you upgrade.
Requirements
- PHP 8.3 or higher
- Laravel 13
- A cache store that supports atomic locks and is shared between your web workers and the scheduler (Redis, database, Memcached or DynamoDB)
- A Cirrux account. The calendar endpoints only accept user-scoped OAuth tokens; a workspace API key gets
403 Forbidden.
Installation
composer require cdsmths/laravel-cirrux-calendar
Publish and run the migrations. They create the cirrux_tokens table, which stores the tokens with the refresh token encrypted, and the cirrux_bookings table, which holds the bookings the package made.
php artisan vendor:publish --tag=cirrux-migrations
php artisan migrate
Publishing the config file is optional:
php artisan vendor:publish --tag=cirrux-config
Configuration
CIRRUX_CACHE_STORE=redis # must support locks and be shared
CIRRUX_CLIENT_ID=cirrux-cli # see the note below
| Key | Default | Meaning |
|---|---|---|
cirrux.base_url |
https://api.cirrux.co/ |
API host |
cirrux.client_id |
cirrux-cli |
OAuth client used for the device flow |
cirrux.cache.store |
default store | Cache store for the week cache, the calendar list, locks and the rate limiter |
cirrux.cache.ttl |
86400 |
Seconds cached events are kept as "last known good"; at least max_staleness |
cirrux.cache.max_staleness |
7200 |
Seconds after a refresh that cached events are still trusted; older data is not served |
cirrux.cache.weeks_ahead |
4 |
Weeks a refresh fetches ahead, counted from now (it starts two days back); max_days_ahead must stay a week below this |
cirrux.cache.calendars_ttl |
86400 |
Seconds the calendar list stays cached |
cirrux.lock.timeout |
10 |
Seconds to wait for the lock of a calendar |
cirrux.rate_limit.per_minute |
60 |
Local limit, well below the 600/min that Cirrux allows per token |
cirrux.event_types.*.calendar |
none | UUID of the calendar that an event type uses; cirrux:refresh refreshes every calendar named here |
cirrux.event_types.*.url |
CIRRUX_DEMO_URL for demo |
An http or https link put on every event a booking creates, for example a fixed video call room |
cirrux.event_types.*.location |
none | A location put on every event a booking creates |
The package checks this config when your app boots and throws an InvalidConfigurationException that names the key. It rejects numbers that are out of range, a ttl below max_staleness, an unknown time zone, a max_days_ahead beyond (weeks_ahead - 1) * 7, a max_staleness of more than a day (86400), a buffer_before of more than 1440 minutes, a duration + buffer_after of more than 1440 minutes, malformed working hours and, outside the testing environment, a cache store on the array or null driver (locks do not work between processes there).
The limits keep availability inside the cache: a refresh fetches [now - 2 days, now + weeks_ahead weeks), and the events that a slot window needs reach at most a day plus the buffers beyond it. With the limits above, a read made within max_staleness of a refresh always fits in what that refresh fetched.
[!NOTE] Cirrux has no way to register your own OAuth client yet, so the package defaults to the client id of the Cirrux CLI. That is fine for your own account. We have asked Cirrux for an official route for third-party apps.
Logging in
The package uses the OAuth device flow, so no callback route is needed:
php artisan cirrux:login
The command shows a URL and a code. Open the URL, enter the code and approve the access. The command waits until you do, then stores the tokens.
From then on the package refreshes the access token by itself: ahead of time when it is about to expire, and once more after a 401. Cirrux rotates the refresh token on every refresh. The package takes a lock and reads the store again before it refreshes, so two processes never spend the same refresh token.
php artisan cirrux:logout # deletes the stored tokens
Usage
Calendars
php artisan cirrux:calendars # name, UUID and color of each calendar
This command always asks Cirrux and refreshes the cache. Use it to find the UUIDs you need in your config.
use Codesmiths\CirruxCalendar\Facades\Cirrux;
$calendars = Cirrux::calendars(); // Collection<int, Calendar> with uuid, name, color
Cirrux::calendars() reads through the cache (cirrux.cache.calendars_ttl).
Events
use Carbon\CarbonImmutable;
use Codesmiths\CirruxCalendar\Data\Attendee;
use Codesmiths\CirruxCalendar\Data\NewEvent;
use Codesmiths\CirruxCalendar\Facades\Cirrux;
$events = Cirrux::calendar($uuid)->events()
->between(CarbonImmutable::parse('2026-11-02'), CarbonImmutable::parse('2026-11-09'))
->get(); // Collection<int, CalendarEvent>
$event = Cirrux::calendar($uuid)->events()->create(new NewEvent(
title: 'Demo',
start: CarbonImmutable::parse('2026-11-02 09:00', 'Europe/Amsterdam'),
end: CarbonImmutable::parse('2026-11-02 09:30', 'Europe/Amsterdam'),
timezone: 'Europe/Amsterdam',
attendees: [new Attendee('jane@example.com', 'Jane')],
));
Cirrux::calendar($uuid)->events()->delete($event->id);
Good to know:
- All times are UTC. Every
CalendarEventholdsCarbonImmutableinstants in UTC. Convert them for display only. between()matches on overlap. An event belongs to the window[from, to)when it overlaps it, including one that starts beforefrom. The package follows every page of results and removes duplicates.- Cirrux mails the attendees. Each attendee gets an invitation when the event is created and a cancellation when it is deleted. You don't need to send confirmation emails yourself.
- Times are per minute.
NewEventrejects a start or end with seconds, an end that is not after the start, and a time in the repeated hour of a DST change. Cirrux receives a wall-clock time without an offset, so such a time would mean two different moments. - Deleting is idempotent. An event that is already gone counts as deleted.
- Writes are sent once. Creating and deleting are never retried, so a request that did reach Cirrux cannot create a second event.
[!IMPORTANT]
get()reads from the week cache and never calls Cirrux, so it is safe on pages that visitors open.fresh()skips the cache and goes to the API: don't use it on visitor paths.
$events = Cirrux::calendar($uuid)->events()->fresh()->between($from, $to)->get(); // asks Cirrux
Caching
The package keeps the events of the coming weeks in your cache, one entry per calendar per ISO week (UTC). Visitors only ever read from there, so the number of API calls does not depend on how many people open your page.
- Refreshed by a job, not by visitors.
Cirrux::refresh()fetches[now - 2 days, now + cirrux.cache.weeks_ahead weeks)in one go (following every page). It takes the calendar lock first; that lock coordinates refreshes and bookings with each other. The write-through of creating and deleting events does not take it, so a refresh that runs at the same moment can overwrite a write-through, or the other way around. That lasts until the next successful refresh; if refreshing keeps failing, reads fail closed once the data is older thanmax_staleness. Booking does a fresh check against Cirrux, so this cannot cause a double booking. Only when all pages arrived are the weeks replaced, so a failure halfway (a429, a5xx) leaves the old cache in place. - Fail closed. When the cache cannot vouch for a window,
get()throws aStaleCalendarExceptioninstead of returning an empty list: the weeks were never fetched, have expired, are older thancirrux.cache.max_staleness, or the window lies outside the refreshed weeks. "No data" is never read as "no events". - Write-through. Creating or deleting an event through the package updates the cached week right away, so your own changes usually show up without waiting for the next refresh (see the race described above).
- Changes made in Cirrux itself show up after one poll. An event you add, move or delete in Cirrux directly is only seen at the next refresh, so with a five minute schedule it can take up to five minutes.
- A shared, lock-capable store. The week cache, the locks and the rate limiter use
cirrux.cache.store. It must support atomic locks and be shared between your web workers and the scheduler (Redis, database, Memcached or DynamoDB).
Cirrux::refresh(); // every calendar named in cirrux.event_types
Cirrux::refresh($uuid); // one calendar
Cirrux::forget(); // empty the cache of every configured calendar
Cirrux::forget($uuid); // empty the cache of one calendar; reads fail closed until the next refresh
Without an argument refresh() tries every configured calendar and then throws the first CirruxException it caught. The failure events are dispatched for each calendar that failed.
Availability
An event type in cirrux.event_types describes what can be booked and when. Cirrux::availability() turns it, together with the cached events of its calendar, into free slots:
use Carbon\CarbonImmutable;
use Codesmiths\CirruxCalendar\Facades\Cirrux;
$slots = Cirrux::availability('demo')
->between(CarbonImmutable::parse('2026-11-02'), CarbonImmutable::parse('2026-11-16'))
->inTimezone('America/New_York')
->slots(); // Collection<int, Slot>, each with start and end
$free = Cirrux::availability('demo')->isFree(CarbonImmutable::parse('2026-11-02 09:00', 'Europe/Amsterdam'));
The event type needs a calendar; without one Cirrux::availability() throws an InvalidConfigurationException. inTimezone() only changes how the slots are presented: the instants stay the same, and an unknown zone throws an InvalidArgumentException.
The rules:
- The grid lives in local time. Slots are laid out from the working hours in the
timezoneof the event type, everyslot_intervalminutes, and converted to instants afterwards. A slot keeps its local time across a DST change. - Slots around a DST change can disappear. A slot whose start or end falls in the hour that does not exist (spring) or occurs twice (autumn), or that would not last
durationminutes of real time, is not offered. - Buffers surround every opaque event. A slot needs
buffer_beforefree minutes before it andbuffer_afterafter it. With 10 minute buffers and an event from 10:00 to 11:00, the slots from 09:30 up to and including 11:00 are gone; the last free slot before it is 09:00 (its buffer ends at 09:40) and the next after it is 11:30 (the buffer before it starts at 11:20). - Transparent events are ignored. Only events that show you as busy block a slot.
- An all-day event blocks the whole local day of the event type, not the UTC date it is stored under.
- Notice and horizon. A slot must start at least
min_noticeminutes from now and less thanmax_days_aheaddays from now. The window you pass is clipped to that; a window that is empty afterwards returns an empty collection without reading the cache.
[!IMPORTANT] Fail closed.
slots()reads the week cache and never calls Cirrux, so it is safe on visitor pages. When the cache cannot vouch for the requested window (the refresh metadata is missing or damaged, it is older thanmax_staleness, the window lies outside what the refresh covered, or a week bucket is missing) it returns an empty collection instead of guessing, and dispatchesCalendarStale, at most once permax_stalenessper calendar. Other exceptions are not caught.
isFree()is the opposite: unless the start is not a valid slot (then it returnsfalsewithout a call), it asks Cirrux for fresh events and letsCirruxRequestExceptionandRateLimitedExceptionthrough. Don't call it from visitor pages.
Booking
use Carbon\CarbonImmutable;
use Codesmiths\CirruxCalendar\Data\Attendee;
use Codesmiths\CirruxCalendar\Facades\Cirrux;
$booking = Cirrux::book(
'demo',
CarbonImmutable::parse('2026-11-02 09:00', 'Europe/Amsterdam'),
new Attendee('jane@example.com', 'Jane'),
'Interested in the API', // optional notes, stored as the description of the event
title: 'Demo for Acme Inc.', // optional; null, empty or whitespace uses the title of the event type
url: 'https://meet.example.com/acme-demo', // optional video call link; falls back to the url of the event type
location: 'Online', // optional; falls back to the location of the event type
); // Booking
Cirrux::cancel($booking); // or Cirrux::cancel($booking->id)
book() does this, in order:
-
Validates the start against the event type: on the grid, inside the working hours, far enough ahead, not too far ahead, not in the past and not in an ambiguous DST hour. A start that fails throws an
InvalidSlotExceptionwhose$reasonis aSlotRejection(in_past,too_soon,too_far_ahead,off_grid,outside_hoursorambiguous_time). Nothing is sent to Cirrux. -
Takes the calendar lock (the one refreshes use) and waits at most
cirrux.lock.timeoutseconds. When it cannot get it, it throws aSlotUnavailableExceptionwith reasonlock_timeout. -
Checks the calendar with Cirrux itself, not the cache, with buffers included. When the time is taken it throws a
SlotUnavailableExceptionwith reasonbusy. This check is what rules out a double booking, whatever the cache holds. -
Dispatches
BookingCreating. A listener can hold the booking back by throwing aBookingVetoedException; nothing has been created at that point. -
Creates the event in Cirrux, with the attendee, and Cirrux mails the attendee an invitation. The event gets the
title,urlandlocationyou passed (trimmed), or those of the event type when you passed none. Cirrux puts them in the invitation, so a video call link reaches the attendee without extra mail. Aurlthat is not http or https throws anInvalidArgumentExceptionbefore anything is sent.The package does not create meetings. For a fixed room, set
urlon the event type. For a meeting per booking, create it in your app (with the Zoom or Google API, say) before you callbook(), and pass its link. -
Stores the booking in
cirrux_bookings, releases the lock and dispatchesBookingCreated.
If storing the booking fails (the database is down, say), the event is deleted from the calendar again and the original exception is thrown; a failure of that delete is reported with report() and does not hide the original one. If Cirrux creates the event but the answer never reaches you (a timeout), there is an event without a booking in Cirrux: the next refresh shows that time as busy.
Every refusal of the slot (an invalid start, a busy calendar, a lock timeout or a veto) dispatches BookingRejected, with the reason, after the lock is released. A wrong argument, such as a url that is not http or https, throws an InvalidArgumentException and dispatches nothing.
Event::listen(BookingCreating::class, function (BookingCreating $event): void {
if (isBlocked($event->attendee->email)) {
throw new BookingVetoedException('This address cannot book.');
}
});
cancel() takes the same lock, deletes the event (Cirrux mails the attendees a cancellation; an event that is already gone counts as deleted), marks the booking as cancelled by the user and dispatches BookingCancelled. It is idempotent: a booking that is already cancelled does nothing and dispatches nothing. An unknown id throws a BookingNotFoundException. When deleting the event fails, the booking stays as it was. When the lock cannot be taken in time it throws a CirruxRequestException.
book() calls Cirrux while it holds the lock, so don't call it where a request has to stay fast; run it in a controller action or a job.
Reconciliation
After every successful refresh the package compares the bookings in the refreshed window with the events Cirrux returned. A booking whose event is missing in two successive refreshes is marked as cancelled externally and BookingCancelled is dispatched with CancelledBy::External; one missing event is only noted, so a single incomplete answer cannot cancel your bookings. A booking whose event moved gets its new times and BookingRescheduled is dispatched. An event that is moved outside the refresh window (further than weeks_ahead weeks ahead) looks deleted and is treated like that after two refreshes.
Scheduling
The package schedules nothing by itself. Add the refresh to your scheduler, for example in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('cirrux:refresh')->everyFiveMinutes()->withoutOverlapping();
php artisan cirrux:refresh # every calendar named in cirrux.event_types
php artisan cirrux:refresh <uuid> # one calendar
The command prints one line per calendar and exits with a failure code when any of them could not be refreshed. Without configured calendars it warns and succeeds.
Laravel events
The package dispatches its own events, so you can build on top of it without changing it: post to Slack, create a lead in your CRM, or raise an alert when the connection breaks.
| Event | When | Payload |
|---|---|---|
LoggedIn |
cirrux:login succeeded |
expiresAt |
LoggedOut |
cirrux:logout |
— |
TokenRefreshed |
The access token was refreshed | expiresAt |
TokenRefreshFailed |
Refreshing failed for good; someone must run cirrux:login |
CirruxFailure |
CalendarEventCreated |
An event was created through the package | calendarUuid, CalendarEvent |
CalendarEventDeleted |
An event was deleted through the package (also when it was already gone) | calendarUuid, eventId |
CalendarRefreshed |
A calendar was refreshed | calendarUuid, from, to, eventCount, apiCalls |
CalendarRefreshFailed |
The refresh failed; the previous cache normally stays in place, unless it was forgotten first (e.g. after deleting a recurring event), in which case reads stay fail closed | calendarUuid, CirruxFailure |
CalendarStale |
Availability returned no slots because the cache cannot vouch for the requested window (see Availability); at most once per max_staleness per calendar |
calendarUuid, refreshedAt (null when there is no usable refresh metadata) |
RateLimited |
Cirrux answered 429 or the local limiter refused a request |
retryAfter |
BookingCreating |
A slot is valid and free and nothing has been written yet; throw a BookingVetoedException from a listener to refuse the booking |
eventType, Slot, Attendee, notes, title, url, location |
BookingCreated |
A booking was stored (after the database commit) | Booking |
BookingRejected |
book() refused the slot (invalid start, busy, lock timeout or veto) |
eventType, start, Attendee, reason: a SlotRejection value, busy, lock_timeout or vetoed |
BookingCancelled |
A booking was cancelled, by cancel() or by reconciliation (after the database commit) |
Booking, CancelledBy (user or external) |
BookingRescheduled |
Reconciliation found that the event of a booking moved (after the database commit) | Booking, previousStart, previousEnd |
All events live in Codesmiths\CirruxCalendar\Events. Their payloads are DTOs and scalars, so they are safe for queued listeners, and they never contain tokens. A failure travels as a CirruxFailure (exception class, message, status, retryAfter) and not as the exception itself, because an exception holds closures that PHP cannot serialize. Renaming or removing a payload field is a breaking change.
Exceptions
Everything the package throws extends Codesmiths\CirruxCalendar\Exceptions\CirruxException:
| Exception | Meaning |
|---|---|
CirruxAuthException |
Not logged in, or the tokens can no longer be refreshed |
CirruxRequestException |
Cirrux refused the request or could not be reached; status() returns the HTTP status (0 when no response came back) |
RateLimitedException |
Cirrux or the local limiter says to slow down; $retryAfter holds the seconds to wait |
StaleCalendarException |
The week cache is missing, expired, older than max_staleness or does not cover the window; $calendarUuid holds the calendar |
InvalidConfigurationException |
The cirrux config cannot be used; thrown when the app boots and names the key |
InvalidSlotException |
The start is not a valid slot of the event type; $reason is a SlotRejection |
SlotUnavailableException |
The slot was valid but could not be booked; $reason is busy or lock_timeout |
BookingVetoedException |
Thrown by a BookingCreating listener to refuse a booking |
BookingNotFoundException |
No booking has that id; $bookingId holds it |
Replacing behaviour
Every action with side effects is a small class under Codesmiths\CirruxCalendar\Actions with a single handle() method, resolved from the container. To change one, bind your own implementation:
$this->app->bind(\Codesmiths\CirruxCalendar\Actions\Events\CreateEvent::class, MyCreateEvent::class);
Roadmap
| Release | Adds |
|---|---|
| 0.1 (this beta) | Device login, token refresh, calendars, reading, creating and deleting events |
| this release | Week cache refreshed by a scheduled job, cirrux:refresh, write-through, fail closed when data is stale, config validation, availability: slots from working hours, buffers, minimum notice and time zones, including DST changes |
| this release | Booking and cancelling with a per-calendar lock, a fresh check before booking, a cirrux_bookings table and reconciliation |
The full design is in plan.md (in Dutch).
Out of scope, on purpose: booking pages, forms, reschedule links, meeting links, reminders, teams, round-robin and payments. Your app builds those on top of the package and its events.
Testing
composer test # lint, static analysis, type coverage and unit tests
composer test:lint # Pint and Rector (dry run)
composer test:types # Larastan, level max
composer test:type-coverage
composer test:unit # Pest, requires exactly 100% code coverage
The unit and feature tests never make real HTTP calls.
Contract tests
Because the API is undocumented, an opt-in suite runs against a real Cirrux account to notice when the API behaves differently from what the package assumes. It creates events in a test calendar and deletes them afterwards. Without credentials, the suite is skipped.
CIRRUX_TEST_CALENDAR=<uuid of an empty calendar> \
CIRRUX_REFRESH_TOKEN=<refresh token> \
composer test:contract
Use a calendar you don't use for anything else. Cirrux rotates the refresh token, so the token you pass works for one run only.
Contributing
Issues and pull requests are welcome. Before you open a pull request:
- run
composer lintandcomposer test, and keep coverage at 100%; - follow the existing style:
declare(strict_types=1),finalclasses,CarbonImmutable, actions for side effects; - if you notice the API behaving differently from the docblocks in
src/Http/Requests, an issue with the request and response (tokens removed) helps a lot.
Security
Please don't report security issues in public issues. Email thomas@codesmiths.nl instead.
License
The MIT License. See LICENSE.