DEV Community

Cover image for When Counting Overlapping Bookings Is Not Enough: Capacity-Aware Availability in PHP
Alkin Veysal
Alkin Veysal

Posted on

When Counting Overlapping Bookings Is Not Enough: Capacity-Aware Availability in PHP

Imagine you're building a scheduling feature for a loading dock, a shared workspace, or a storage area.

The resource has a maximum capacity. A reservation occupies some of it for a period of time. You need to answer a deceptively simple question:

Can I reserve 2 units for 45 minutes, and if so, when can I start?

At first, this looks like a database query that counts overlapping reservations. I initially thought of it in similar terms. But once capacity changes throughout the day and bookings consume different amounts, counting overlaps no longer gives the right answer.

That was the starting point for CapacityAvailability, a small PHP library I recently released as version 1.0.0.

This article is about the problem and the reasoning behind the solution—not an argument that every scheduling application needs another dependency.

The trap: overlap is not concurrency

Suppose a resource has capacity 3 and already has these bookings:

Booking Time Units
A 09:00–09:30 1
B 09:30–10:00 1
C 10:00–10:30 1

A proposed reservation would use 2 units from 09:00 to 10:30.

All three existing bookings intersect that proposed interval. A naive approach might sum their units, get 3, and reject the request.

But the existing bookings never run simultaneously. At every instant, only one unit is occupied. Two units remain free throughout the entire 90-minute interval. The request fits.

Now add another booking for 2 units from 09:45 to 10:15. The answer changes: during part of the interval, all 3 units are occupied.

What matters is usage at each point in time, not how many reservations touch the requested interval.

And there's another complication: the resource itself might have capacity 2 before 10:00 and capacity 3 afterwards.

Model time explicitly

I chose a small model:

  • A capacity interval declares the total available units during [start, end).
  • A reservation consumes a positive number of units during [start, end).
  • A query specifies the required units and the duration for which those units must remain available.

The [start, end) notation matters. The start is inclusive; the end is exclusive. A booking ending exactly at 10:00 does not conflict with another beginning at 10:00.

Capacity declarations are absolute, not additive: if a pool has capacity 3, that's a level of 3, not another 3 units to add to an overlapping declaration. Overlapping capacity declarations are rejected. Any period without declared capacity has capacity zero.

This model also makes a deliberate trade-off: the units are interchangeable. It answers whether 2 places are available, not which exact two places to allocate. Scheduling individual machines with different capabilities is a different problem.

Sweep-line: look only where the state changes

The underlying approach is a sweep-line algorithm.

Instead of examining every second or minute, we collect the moments at which something changes:

  1. When capacity begins, add its units.
  2. When capacity ends, subtract its units.
  3. When a reservation begins, increase occupied units.
  4. When a reservation ends, decrease occupied units.
  5. Sort the event times and apply all changes at the same instant together.

Between two consecutive event times, capacity and usage are constant.

For example:

Period Capacity Occupied Free
09:00–09:30 2 0 2
09:30–10:00 2 1 1
10:00–10:30 3 1 2
10:30–11:00 3 0 3

If we need 2 units for 45 minutes, the period from 09:00 to 09:30 is too short. The contiguous availability from 10:00 to 11:00 is long enough.

The result is not a predefined array of quarter-hour slots. It's a continuous window with enough remaining capacity for the entire requested duration.

For C capacity intervals and R reservations, the calculation uses O((C + R) log(C + R)) time and O(C + R) additional memory, dominated by sorting the event boundaries.

Trying it in PHP

Install the package:

composer require alkin/capacity-availability:^1.0
Enter fullscreen mode Exit fullscreen mode

Here's the same example using the public API:

<?php

declare(strict_types=1);

use Alkin\CapacityAvailability\AvailabilityCalculator;
use Alkin\CapacityAvailability\CapacityInterval;
use Alkin\CapacityAvailability\Reservation;

$at = static fn (string $time): DateTimeImmutable =>
    new DateTimeImmutable("2026-10-22 {$time}:00+00:00");

$capacity = [
    new CapacityInterval($at('09:00'), $at('10:00'), 2),
    new CapacityInterval($at('10:00'), $at('11:00'), 3),
];

$reservations = [
    new Reservation($at('09:30'), $at('10:30'), 1),
];

$calculator = new AvailabilityCalculator();

$windows = $calculator->availableWindows(
    capacity: $capacity,
    reservations: $reservations,
    from: $at('09:00'),
    until: $at('11:00'),
    requiredUnits: 2,
    durationSeconds: 45 * 60,
);

foreach ($windows as $window) {
    printf(
        "%s–%s; latest start: %s\n",
        $window->start->format('H:i'),
        $window->end->format('H:i'),
        $window->latestStart->format('H:i'),
    );
}
Enter fullscreen mode Exit fullscreen mode

Output:

10:00–11:00; latest start: 10:15
Enter fullscreen mode Exit fullscreen mode

That last field is worth highlighting: 10:15 is a valid starting time. A 45-minute booking starting at 10:15 ends at 11:00, exactly at the exclusive boundary.

If your UI only offers 15-minute slots, it can choose to display 10:00 and 10:15. But the calculation itself shouldn't impose that grid.

The library also exposes canBook() for an exact interval and timeline() for inspecting capacity, occupancy, and free units.

The important distinction: available is not reserved

So far, we've solved a deterministic calculation. We have not solved concurrent booking.

Imagine two requests arriving at the same time. Each reads the database and sees two free places. Each wants two places. Both call canBook(), both get true, and both insert their booking.

The calculation can be completely correct while the final database state is overbooked.

That's why the library separates the pure algorithm from an optional Doctrine DBAL adapter for a particular MariaDB/InnoDB schema.

The adapter's booking path is, in simplified terms:

BEGIN TRANSACTION
    SELECT pool_id
    FROM capacity_pools
    WHERE pool_id = :pool
    FOR UPDATE

    -- Read capacity and overlapping bookings
    -- Recalculate availability while holding the pool lock
    -- Insert only when the request fits
COMMIT
Enter fullscreen mode Exit fullscreen mode

The stable pool row acts as the synchronization point. Independent writers for the same pool must acquire that lock before reading the snapshot used for a write decision.

This comes with a serious condition: every code path that changes capacity or reservations for that pool must follow the same locking protocol. A cancellation, reschedule, import, or manual SQL update that bypasses it can invalidate the guarantee.

The adapter also intentionally does not provide idempotency, automatic deadlock retries, or arbitrary ORM mappings. It rejects nested transactions; its behavior was verified for the documented MariaDB setup, not every database engine or isolation configuration.

I would rather document these boundaries clearly than hide them behind a convenient-looking API.

Testing two different kinds of correctness

There were two separate questions to test:

Is the algorithm giving the correct answer?

Alongside ordinary unit tests, I used an independent reference implementation that samples every minute—or every second for smaller calendars—and compares all possible starts against the sweep-line result.

The release was checked using 300 randomized minute-resolution calendars, 80 second-resolution calendars, and 77,525 reference assertions, plus unit tests and static analysis.

Does the database adapter prevent the race?

This needed actual concurrent processes, not two sequential method calls.

In an isolated MariaDB 11.3.2 test database, I ran scenarios with independent PHP workers. The suite includes an intentionally unsafe example that demonstrates overbooking, followed by properly locked transactions.

The recorded result was 12 scenarios, 43 independent PHP processes, and 198 assertions. The unsafe test is deliberately confined to disposable test data.

Those tests are useful evidence for the documented design; they are not a promise that every application integration is automatically safe.

Why this isn't a Symfony bundle

I work primarily with Symfony, and the early experiments included a real Symfony application using DoctrineBundle. It was tempting to package everything as a Symfony bundle.

But the calculation itself doesn't need Symfony. It just takes arrays of immutable value objects and returns results.

In a Symfony application, the calculator can be registered as a normal service:

# config/services.yaml
services:
    Alkin\CapacityAvailability\AvailabilityCalculator: ~
Enter fullscreen mode Exit fullscreen mode

That was enough to integrate the core using dependency injection. The optional Doctrine classes can be registered similarly when the application uses the documented persistence schema.

A bundle may make sense one day if it provides meaningful integration behavior. Adding one merely to wrap a service registration would increase maintenance without solving another problem.

Where it stops

CapacityAvailability is not a complete booking platform.

It does not handle recurring calendars, holidays, payments, user permissions, named individual resources, or multi-pool optimization. It also doesn't magically adapt its database writer to an existing application's schema.

What it does offer is a focused solution to one recurring question:

Given changing capacity and overlapping usage, when does a request fit for its entire duration?

Keeping that boundary explicit is part of the design.

The project is open source under MIT, supports PHP 8.2+, and the calculation core has no required runtime dependencies.

If you've solved a similar scheduling or capacity problem, I'd be interested in the edge cases you ran into—especially where the simple interchangeable-capacity model stops being sufficient.

Top comments (0)