Search by

eril / calendary

erilshackle

A lightweight, framework-agnostic PHP library for calendar availability, busy periods and bookable time slots.

v1.0.1 2026-09-27 12:23 UTC

This package is auto-updated.

Last update: 2026-09-27 22:22:49 UTC


README

A lightweight, framework-agnostic PHP library for resolving calendar availability, busy periods and bookable time slots.

Latest Stable Version Total Downloads License Tests

Calendary lets you define when a resource can be available, add periods when it is unavailable, and query the resulting calendar without coupling your scheduling logic to a database, framework or external calendar provider.

use Eril\Calendary\Calendary;

$calendar = Calendary::load([
    'weekly' => [
        1 => [['09:00', '12:00'], ['14:00', '18:00']],
        2 => [['09:00', '18:00']],
        3 => [['09:00', '18:00']],
        4 => [['09:00', '18:00']],
        5 => [['09:00', '17:00']],
    ],

    'dates' => [
        '2026-10-08' => [['10:00', '14:00']],
    ],
]);

$calendar
    ->timezone('Atlantic/Cape_Verde')
    ->duration(60)
    ->interval(30)
    ->daysOff(['2026-10-07'])
    ->holidays(['2026-10-12'])
    ->busy([
        ['2026-10-05 10:00'],
        ['2026-10-05 14:20', '2026-10-05 15:40'],
    ]);

$result = $calendar
    ->query()
    ->available()
    ->availableSlots()
    ->between('2026-10-05', '2026-10-12');

Features

  • Recurring weekly availability
  • Date-specific availability overrides
  • Configurable slot duration and interval
  • Busy dates and time periods
  • Days off and holidays
  • Timezone-aware date resolution
  • Available and busy slot detection
  • Single-day, weekly and range queries
  • Day status filtering
  • Available-slot filtering
  • Selectable output fields
  • Array and JSON serialization
  • Framework and database independent

Requirements

  • PHP 8.2 or later

Installation

Install Calendary using Composer:

composer require eril/calendary

Quick Start

Create a calendar by defining its recurring weekly availability:

use Eril\Calendary\Calendary;

$calendar = Calendary::load([
    'weekly' => [
        1 => [['09:00', '12:00'], ['14:00', '18:00']],
        2 => [['09:00', '18:00']],
        3 => [['09:00', '18:00']],
        4 => [['09:00', '18:00']],
        5 => [['09:00', '17:00']],
    ],
]);

$calendar
    ->timezone('Atlantic/Cape_Verde')
    ->duration(60);

Weekly availability uses ISO-8601 weekdays:

Value Day
1 Monday
2 Tuesday
3 Wednesday
4 Thursday
5 Friday
6 Saturday
7 Sunday

You can then resolve a date:

$day = $calendar
    ->query()
    ->on('2026-10-05');

And inspect the result:

$day->status();
$day->available();
$day->slots();
$day->availableSlots();

$day->toArray();
$day->toJson();

Availability

Weekly availability

The weekly definition represents recurring availability.

$calendar = Calendary::load([
    'weekly' => [
        1 => [
            ['09:00', '12:00'],
            ['14:00', '18:00'],
        ],

        2 => [
            ['09:00', '18:00'],
        ],
    ],
]);

A resource may have multiple availability periods on the same day.

Date-specific availability

Use dates to define availability for a specific date:

$calendar = Calendary::load([
    'weekly' => [
        4 => [['09:00', '18:00']],
    ],

    'dates' => [
        '2026-10-08' => [['10:00', '14:00']],
    ],
]);

Date-specific availability completely overrides weekly availability for that date.

In the example above, October 8 is available from 10:00 to 14:00, regardless of the recurring Thursday schedule.

An empty date definition explicitly closes the date:

'dates' => [
    '2026-10-08' => [],
]

Duration and Interval

Duration

The duration defines how long each generated slot lasts.

$calendar->duration(60);

With availability from 09:00 to 12:00, this produces:

09:00 - 10:00
10:00 - 11:00
11:00 - 12:00

Interval

The interval defines the distance between candidate slot start times.

$calendar
    ->duration(60)
    ->interval(30);

This produces:

09:00 - 10:00
09:30 - 10:30
10:00 - 11:00
10:30 - 11:30
11:00 - 12:00

A slot is generated only when its complete duration fits inside the availability period.

When no interval is explicitly configured, the duration is used as the interval.

Break Time

Use breakTime() when additional unavailable time is required after a booking:

$calendar
    ->duration(60)
    ->interval(15)
    ->breakTime(15);

Each setting has a different responsibility:

  • duration(60) — each slot lasts 60 minutes.
  • interval(15) — candidate slots start every 15 minutes.
  • breakTime(15) — adds 15 minutes of unavailable time after an implicitly timed booking.

For example:

$calendar->busy([
    ['2026-10-05 09:00'],
]);

With a 60-minute duration and a 15-minute break time, the booking lasts from 09:00 to 10:00, while the calendar remains busy until 10:15.

Break time does not change the duration of the slot itself.

It is only applied when Calendary infers the end of a busy period. Explicit periods are respected exactly as provided:

$calendar->busy([
    ['2026-10-05 09:00', '2026-10-05 10:00'],
]);

The period above remains 09:00–10:00, even when breakTime() is configured. Whole-day busy entries are also not extended.

Busy Time

Busy periods represent time that would otherwise be available but is already occupied.

$calendar->busy([
    ['2026-10-05 10:00'],
    ['2026-10-05 14:20', '2026-10-05 15:40'],
    ['2026-10-06'],
]);

Calendary supports three forms.

A date blocks the complete day:

['2026-10-06']

A datetime blocks a period using the configured duration:

['2026-10-05 10:00']

With a duration of 60 minutes, this represents 10:00 to 11:00.

An explicit start and end define the exact busy period:

[
    '2026-10-05 14:20',
    '2026-10-05 15:40',
]

Busy periods may span multiple calendar dates.

Calendary uses half-open time periods:

[start, end)

Therefore a busy period from 10:00 to 11:00 does not conflict with a slot beginning exactly at 11:00.

Days Off and Holidays

Dates may be explicitly blocked as days off:

$calendar->daysOff([
    '2026-10-07',
    '2026-10-09',
]);

Or holidays:

$calendar->holidays([
    '2026-12-25',
    '2027-01-01',
]);

Both take precedence over weekly and date-specific availability.

They remain separate concepts so consumers can distinguish their reason through the resolved day status.

Timezones

Set the timezone used for calendar resolution:

$calendar->timezone('Atlantic/Cape_Verde');

A DateTimeZone instance is also accepted:

$calendar->timezone(
    new DateTimeZone('Atlantic/Cape_Verde')
);

When no timezone is explicitly configured, Calendary uses PHP's current default timezone.

Querying

Queries are created using:

$query = $calendar->query();

Single date

$day = $calendar
    ->query()
    ->on('2026-10-05');

Returns a Day.

Week

$week = $calendar
    ->query()
    ->weekOf('2026-10-07');

Returns the ISO week containing the date, from Monday through Sunday.

Date range

$range = $calendar
    ->query()
    ->between(
        '2026-10-01',
        '2026-10-31'
    );

Ranges are inclusive.

Both October 1 and October 31 belong to the requested range.

Filtering Days

Filter results by day status:

$result = $calendar
    ->query()
    ->status('open')
    ->between($from, $to);

Multiple statuses may be accepted:

$result = $calendar
    ->query()
    ->status('open', 'full')
    ->between($from, $to);

Available day statuses are:

open
closed
full
day_off
holiday

You may also return only days containing at least one available slot:

$result = $calendar
    ->query()
    ->available()
    ->between($from, $to);

Filtering Slots

Use availableSlots() to remove busy slots from returned days:

$day = $calendar
    ->query()
    ->availableSlots()
    ->on('2026-10-05');

This affects the returned slot collection but does not change the resolved status of the day.

It can be combined with day filters:

$result = $calendar
    ->query()
    ->status('open')
    ->availableSlots()
    ->between($from, $to);

Selecting Output Fields

Use select() to control which Day fields are included during serialization:

$result = $calendar
    ->query()
    ->select('date', 'status')
    ->between($from, $to);

Each serialized day will contain only:

[
    'date' => '2026-10-05',
    'status' => 'open',
]

Available fields are:

date
weekday
available
status
slots

Selection affects serialization only.

The result object still retains its complete domain data:

$day = $calendar
    ->query()
    ->select('status')
    ->on('2026-10-05');

$day->toArray();
// ['status' => 'open']

$day->date();       // still available
$day->slots();      // still available
$day->available();  // still available

When used with a range, select() applies to each Day. Range metadata remains available:

$result = $calendar
    ->query()
    ->select('date', 'status')
    ->between(
        '2026-10-05',
        '2026-10-11'
    )
    ->toArray();

Example:

[
    'from' => '2026-10-05',
    'to' => '2026-10-11',
    'timezone' => 'Atlantic/Cape_Verde',

    'days' => [
        [
            'date' => '2026-10-05',
            'status' => 'open',
        ],
        [
            'date' => '2026-10-06',
            'status' => 'open',
        ],
    ],
]

Day Results

A resolved Day provides:

$day->date();
$day->weekday();

$day->status();
$day->available();

$day->slots();
$day->availableSlots();

$day->isAvailable('10:00');

$day->toArray();
$day->toJson();

Possible statuses are:

Day::OPEN;
Day::CLOSED;
Day::FULL;
Day::DAY_OFF;
Day::HOLIDAY;

Their meanings are:

Status Meaning
open At least one generated slot is available
closed No availability is defined for the date
full Availability exists, but all generated slots are busy
day_off The date was explicitly marked as a day off
holiday The date was explicitly marked as a holiday

Slot Results

Each Slot provides:

$slot->start();
$slot->end();

$slot->status();
$slot->available();

$slot->toArray();
$slot->toJson();

Possible statuses are:

Slot::AVAILABLE;
Slot::BUSY;

start() and end() return DateTimeImmutable instances.

Range Results

A range provides:

$range->from();
$range->to();
$range->days();

$range->toArray();
$range->toJson();

Query filters may cause some dates inside the requested range to be absent from days().

For example:

$range = $calendar
    ->query()
    ->status('open')
    ->between(
        '2026-10-01',
        '2026-10-31'
    );

The requested boundaries remain October 1 and October 31, while days() contains only matching days.

Checking a Booking Time

The same calendar rules used to display availability can also be used to validate a requested booking time:

$day = $calendar
    ->query()
    ->on('2026-10-05');

if (!$day->isAvailable('10:00')) {
    throw new DomainException(
        'The requested time is not available.'
    );
}

This helps keep availability display and booking validation based on the same scheduling rules.

For complete usage, concepts, examples, and API details, see the Calendary documentation.

Released under the MIT License.