Time-slot algebra in pure PHP. An immutable, dependency-light toolkit that turns the messy interval math behind every booking system into a handful of well-tested operations:
availability = invert(closed_hours ∪ appointments ∪ blocks)
Compute free time, merge and invert busy periods, and place new appointments into the gaps — without a single off-by-one boundary or hand-rolled "free time" loop scattered across your codebase.
It is deliberately low level and framework-agnostic: the core depends only on Carbon and
ramsey/uuid, so any scheduling, booking or capacity-planning layer can sit on top. An optional
Laravel service provider + facade ship in src/Laravel/ for those who want it.
- Immutable by design. Every operation (
pad(),merge(),cutWith(),invert, …) returns new instances. No hidden mutation of shared state, so results are easy to reason about. - O(log n) lookups.
SlotBagkeeps slots sorted and uses binary search for containment, overlap and insertion queries instead of linear scans — with a PHPBench suite to prove the hot paths stay fast as bags grow. - A real algebra of time.
invert,mergeandsplitover collections of slots, plus per-slotintersect,contains,touches,distanceTo,cutWith,fitInand padding. - Placement algorithms.
FirstFitAlgorithmandBestFitAlgorithm, the latter withAlignmentOption(LEFT / RIGHT / MIDDLE / NONE), behind a pluggableAlgorithmFactory. - A text DSL for ranges.
TimeRangesparses and generates date / time / datetime ranges (2026-06-22...2026-06-24,09:00:00...18:00:00, …) — explicit window required, no hiddennow(), so behaviour is fully deterministic and testable. - Recurring schedules.
WeeklyScheduleis an immutable value object for weekly opening patterns (ISO 1=Mon … 7=Sun) that materializes straight into merged slot bags. - Tested and benchmarked. ~147 PHPUnit tests / 658 assertions, plus a PHPBench suite for the performance-critical paths.
| Type | Responsibility |
|---|---|
TimeSlot |
Immutable window [start, start + duration) with optional tags and payload. |
SlotBag |
Ordered, non-overlapping collection of slots with O(log n) lookup. |
MergedSlotBag |
Same, but adjacent/overlapping slots are auto-merged on insert. |
SlotProcessor |
Functional operations across bags: invertSlotBags(), mergeSlotBags(). |
Algorithms\* |
FirstFitAlgorithm, BestFitAlgorithm (with AlignmentOption LEFT/RIGHT/MIDDLE/NONE). |
Schedule\WeeklySchedule |
Immutable recurring weekly schedule (ISO days 1=Mon … 7=Sun). |
TimeRanges\* |
A small text DSL to parse/generate ranges (dates, times, datetimes). |
Laravel\* |
Optional service provider + facade for Laravel integration. |
- PHP 8.3+
nesbot/carbon^3.0ramsey/uuid^4.7
composer require mdps/slot-engineCompute the free time in a working day given a set of busy periods, then place a 30-minute appointment into the first available gap:
use Carbon\CarbonImmutable;
use SlotEngine\TimeSlot;
use SlotEngine\SlotBag;
use SlotEngine\SlotProcessor;
use SlotEngine\Algorithms\FirstFitAlgorithm;
$day = CarbonImmutable::parse('2026-06-22 00:00:00');
// The window we reason within: a full day.
$window = new TimeSlot($day, 24 * 3600);
// Busy periods (closed hours + existing appointments).
$busy = new SlotBag($window);
$busy->addOne(new TimeSlot($day->setTime(0, 0), 9 * 3600)); // closed until 09:00
$busy->addOne(new TimeSlot($day->setTime(13, 0), 1 * 3600)); // lunch 13:00–14:00
$busy->addOne(new TimeSlot($day->setTime(18, 0), 6 * 3600)); // closed from 18:00
// Free time = invert(busy) within the window.
$free = (new SlotProcessor())->invertSlotBags($busy);
foreach ($free->getSlots() as $s) {
echo $s->getStart()->format('H:i').'–'.$s->getEnd()->format('H:i')."\n";
}
// 09:00–13:00
// 14:00–18:00
// Place the first 30-minute slot that fits, given the busy periods and the window.
$slot = (new FirstFitAlgorithm())->getNextSlot($busy->getSlots(), $window, 30 * 60);
echo $slot->getStart()->format('H:i'); // 09:00composer install
vendor/bin/phpunitThe unit suite is pure (no Laravel required). The Laravel-specific tests under
tests/Unit/Laravel/ are excluded from the package runner by phpunit.xml.dist; they are
meant to run inside a consuming application.
vendor/bin/phpbench run tests/Benchmark --report=default- Immutability everywhere.
TimeSlotoperations (pad(), etc.) return new instances; bags derive new bags. No hidden mutation of shared state. - Ordered storage, O(log n) lookups.
SlotBagkeeps slots sorted and uses binary search for containment/overlap queries instead of linear scans. - Explicit over implicit. The
TimeRangesparser requires an explicit window — there is no hiddennow()fallback — so behaviour is deterministic and testable. - Framework-optional. The core depends only on Carbon + ramsey/uuid; the Laravel layer is a thin, opt-in adapter.
MIT © 2026 mdps
Un proyecto de mdps · 2026 · desarrollado en Murcia.