Skip to content

API reference

The engine is the yoyaku.booking_manager service (\Drupal\yoyaku\BookingManagerInterface): it reserves capacity, runs the constraints and owns each booking's lifecycle.

$manager = \Drupal::service('yoyaku.booking_manager');
// In services, inject \Drupal\yoyaku\BookingManagerInterface.

A surface (a form, a controller, an integration) turning a quantity into bookings goes through hold() rather than hold(): how many bookings a quantity becomes, and what each one occupies, is the resource's to decide.

Every target is named by a slot plus, when the slot offers tariffs, one of its resource's tariffs. A tariff belongs to the resource, so the same one is offered on every slot of it: that is why a booking records both. A slot tariff is optional on top, and only overrides one tariff for one slot, carrying its quota and its price there.

BookingManager

hold()

public function hold(
  array $asks,
  ?string $reference = NULL,
  ?BookingTransactionInterface $into = NULL,
  array $releasing = [],
  bool $consolidate = TRUE,
): BookingResult

The one way to take capacity. All or nothing: if any ask is refused nothing is held, and every ask must belong to one tenant.

Which units an ask gets is the ask's own business, and this is the whole of the distinction a caller has to understand:

// The assignment chooses the units, inside the hold's locks.
BookingAsk::forQuantity($slot, $tariff, 3)
BookingAsk::forQuantity($slot, $tariff, 3, ['section' => 4])   // confined to a part
BookingAsk::forQuantities([['slot' => $slot, 'quantity' => 3]])

// The caller already knows: a booker who clicked this seat.
BookingAsk::fromArrays([
  ['slot' => $slot, 'tariff' => $tariff, 'quantity' => 1, 'fields' => ['place' => $id]],
])

Both kinds travel in one call, so a booker who took one named seat and asked for two more beside it is one hold and one refusal. fromQuantities() marks the ask with BookingAsk::PENDING; the engine reads that mark and never guesses it from the ask's contents, because an ask carrying no fields is not evidence either way.

A hold consolidates before it lets go. Taking places is what leaves a party split. So the hold puts the party back together before it releases its locks, in the same transaction.

It consolidates only inside what those locks already cover. An exchange reaching a part of the resource the hold has not locked is not made at all: a consolidation is not worth a second lock. It costs one statement on a hold with a party on the slot, and nothing on a hold without one, however large the party.

Pass consolidate: FALSE to turn it off. consolidate() turns it off itself, because it reaches the engine through this method, and a hold that offered a consolidation would have that one offer another. Turn it off, too, when building a fixed arrangement rather than serving a booker, so you get the places you named rather than the ones the engine would prefer.

Never work the units out before calling. That choice reads what is free before anything is held, and every booker reaching at once reads it identically: they are all offered the same units and all but one is refused for a unit that was free when they asked. Leaving the ask pending defers the choice to inside the locks, where being offered a unit and taking it are the same moment. On a placed resource, holding a quantity that names nothing is worse than cosmetic: the units take the tariff's capacity while leaving every unit available again, so the resource can be booked twice over.

Every ask on one slot is grouped together before it goes down, because what is asked for on one slot is one party however many tariffs it arrived as, and composing each ask alone is what seats a family apart.

fromArrays() refuses an ask that is not one, so the engine never has to wonder whether it was handed a slot at all. Asking for the type rather than the shape is also why the shape is written down once, here, instead of in every docblock it used to cross.

$reference is the subject handle the constraint plugins are given, so a rule can reason about who or what is asking. An ask's fields are an array keyed by field name: the values written onto the booking, which the engine does not interpret but the constraints receive, and which is how a placement submodule names the place or the area a unit occupies.

A constraint is handed the ask itself, as a BookingAskInterface. Read a field with getFieldValue(), or ask hasFieldValue() whether the ask carries the field at all, which is not the same question: an ask saying its allotment is NULL has chosen none, and one never mentioning it is asking to be given whichever the rules pick. Not hasField(), which is Drupal's question about whether the bundle defines a field and is TRUE either way.

$releasing gives up what $into holds already as part of taking these, under the same locks, which is how a party scattered by earlier clicks is seated together without anything ever leaving the order.

Every hold runs the constraint plugins and the attached constraint policies, twice: once before the slot locks, to reject a doomed ask cheaply, and once under them, which is the pass that decides. There is no second entry point that could miss them.

$into appends to an order already open for editing, which is how a basket accumulates as a visitor clicks; a frozen transaction cannot take more. NULL starts one, with $reference stamped on it as a label. A new transaction is instantiated unsaved and saved once the asks have been validated and the database transaction is under way, so a refusal rolls it back with everything in it and a failed hold leaves no empty transaction behind, without the caller writing any cleanup. An empty order that has just taken its first bookings enters pending, announced like every later transition; appending into one already pending leaves its state to the caller, whose reconcile recomputes it.

Returns a BookingResult: the order (->transaction) the units were held in, plus ->messages, what the surface should show about the placement. Each is a BookingMessage carrying ->text and a ->type that is one of core's MessengerInterface::TYPE_STATUS, TYPE_WARNING or TYPE_ERROR, so a surface maps the type to an appearance rather than choosing one; what the engine sends here is a warning. A party the venue could not seat side by side is held all the same and warned about; a shortfall throws instead.

A surface that catches a refusal words it for the booker and puts it back in the same shape with BookingMessage::createError(), which is how one list reaches the booker whether the engine spoke or refused. Over JSON a message is {text, type}, which is what core's own Drupal.Message takes.

Throws CapacityExceededException if an ask will not fit, PolicyRefusedException if an attached policy refuses, or BookingException if a constraint or subscriber denies it. A pending ask refused for capacity is tried once more unconfined first, since the part it was confined to was itself chosen by a read above the locks.

confirm()

public function confirm(BookingInterface $booking): BookingInterface

Moves a held booking to confirmed. Throws HoldExpiredException if the hold is no longer live.

release() / cancel()

public function release(BookingInterface $booking): BookingInterface  // held → released
public function cancel(BookingInterface $booking): BookingInterface   // confirmed → canceled

Both free capacity. release() is for un-confirmed holds; cancel() is for confirmed bookings.

expireBooking()

public function expireBooking(BookingInterface $booking): BookingInterface

Expires one booking of a lapsed order (held → expired). A booking carries no clock: its order decided the window was spent, and this carries that out one booking at a time. A safe no-op if it was confirmed or released meanwhile, which is what makes expiring an order idempotent however often it is attempted.

Called by TransactionManager::expireTransaction(), never on its own: the window belongs to the order, so the order is what expires.

The hold clock

There is no per-booking deadline and no verb that moves one. An order carries hold_expires, and it is the only hold clock in the system:

Verb Effect
BookingManager::hold() Opens the window on the first booking, and renews it on each later one when the realm renews on change.
TransactionManager::renewTransactionHold($order) Re-arms the window after the basket changed some other way, a booking given up above all. An order holding nothing comes off the clock entirely.
TransactionManager::expireTransaction($order) Under the order's row lock, re-reads the deadline and expires every booking still held.

How long the window is resolves channel → tenant → yoyaku.settings, each key independently: hold_ttl (empty means the basket never lapses, which is how manual approval is said), renew_hold_on_change, and hold_max_lifetime, the ceiling renewal may not reach past. See Capacity and holds.

Locking an order for checkout suspends the window and records it; unlocking puts back that same timestamp, so time at the provider is time spent and a canceled checkout wins no extra window.

getAvailability() / getConsumed()

// Remaining units on a slot, or at one of its tariffs; NULL = unlimited.
public function getAvailability(BookingSlotInterface $slot, ?BookingResourceTariffInterface $tariff = NULL): ?int
// The quantity the consuming bookings take on that same target.
public function getConsumed(BookingSlotInterface $slot, ?BookingResourceTariffInterface $tariff = NULL): int

getAvailabilityMap(array $slots) answers the same question for many slots at once, tariff by tariff, and findAvailability(AvailabilityQuery $query) searches for bookable slots; both are what a calendar reads instead of asking per target.

Rearranging what is already held

public function sortForRelease(BookingSlotInterface $slot, array $droppable, array $held, int $units): array
public function consolidate(BookingSlotInterface $slot, BookingTransactionInterface $order): array
public function moveInto(BookingSlotInterface $slot, BookingTransactionInterface $order, mixed $partition, array $tariff_ids = []): array

Holding and giving back are both changes of how many, and neither repairs an arrangement: a party split by a released unit stays split however many times its quantity is stepped. sortForRelease() decides which unit a reduction gives up first, consolidate() puts a scattered party back together, and moveInto() moves what a booker holds into the part they chose. The last two do it as one atomic exchange under the slot lock, so nothing ever leaves the order and nobody can slip into the unit being vacated.

consolidate() is best effort and silent: a refusal leaves the booker holding exactly what they held a moment ago.

It reaches further than a hold can. A hold consolidates only inside the parts of the resource it has already locked, so it never has to take a second lock. This call starts with no locks and takes whichever it needs, so it can move a party into a part the hold never locked.

That is the division between the two. A hold does what costs it no more than a statement. What is left over is what is worth a read of its own.

book()

// On \Drupal\yoyaku\TransactionManagerInterface, not the booking manager.
public function book(BookingSlotInterface $slot, ?BookingResourceTariffInterface $tariff = NULL, int $quantity = 1, ?string $reference = NULL): BookingResult

Holds and confirms in one step. It lives on the transaction manager because the second step is that service's own: confirming the order the units landed in.

Deleting records

A slot that has bookings, in any state, is not deleted: BookingSlot::preDelete() throws, whoever deletes it, from a form, code or drush. A resource that still has slots or bookings is refused the same way by BookingResource::preDelete(), and once it has neither, its tariffs and allotments go with it. Two methods answer what is left, and the delete forms show it:

public function countBookingsBySlot(array $slot_ids): array
public function countResourceRecordsByEntityType(int $resource_id): array

A slot that has bookings is not deleted. Code removing content it created itself, such as a module's hook_uninstall(), deletes its slots together with their bookings and slot tariffs in one call:

$booking_manager->deleteSlotsWithBookings($slot_ids);

The bookings' orders are left alone, since an order can hold bookings on other slots. Both example modules uninstall this way.

The records a schedule is built from follow the same rule. A tariff that bookings name, a resource type that resources use and a tariff class that tariffs belong to refuse deletion in their own preDelete(), through ReferencingRecords, which counts the records whose entity reference fields still name one:

$counts = \Drupal::service('yoyaku.referencing_records')->countRecordsByEntityType($tariff, ['yoyaku_slot_tariff']);

The second argument names the types that go with the record rather than keep it: a tariff's slot tariffs, and a tariff preset's tariffs and allotments, are deleted with it. A booking that still counts (held, confirmed, completed or a no-show) is refused in access, to everyone: it is released or canceled, not deleted.

Exceptions

classDiagram
    RuntimeException <|-- BookingException
    BookingException <|-- CapacityExceededException
    BookingException <|-- HoldExpiredException
    BookingException <|-- PolicyRefusedException
  • BookingException - base; also thrown when a constraint/subscriber denies a hold.
  • CapacityExceededException - the requested quantity does not fit.
  • HoldExpiredException - an operation needed a live hold but it had lapsed.
  • PolicyRefusedException - a rule about the request as a whole refuses it. The thing is available and this request may not have it, which is a different sentence from "this is taken" and a seat map has to tell them apart.

All live under \Drupal\yoyaku\Exception\.

Two sentences, never one

getMessage() is for the log and for whoever reads a stack trace. It names entity ids and states, it is written in English wherever the site runs, and no booker ever reads it.

What a booker may read is a separate translatable sentence, carried beside it:

throw BookingException::refusal(
  sprintf('Slot tariff %s: 2 of 2 unit(s) left, 10 requested.', $tariff->id()),
  $this->t('Only @count are left, so the @asked you asked for could not be held.', …),
);

$refusal->getTranslatedMessage();   // the booker's sentence, or NULL

A refusal raised with the plain constructor carries no sentence, which is the honest answer for the ones a booker cannot cause: an invariant the engine holds, or a failure on the way to the answer. The accessor is named after core's own UnmetDependenciesException::getTranslatedMessage(), which splits a technical message from a user-facing one the same way.

Surfaces do not read either one directly. They ask yoyaku.booker_refusal:

$errors[] = $this->bookerRefusal->messageFor($e);

which returns the sentence when there is one and otherwise logs the technical message and answers with a generic one. So a message written for a developer cannot reach a page by accident, and a site running in French shows French.

A subscriber denying a hold through BookingHoldRequestedEvent::deny() is in the same position: the reason it gives is shown to the booker as it stands, so it belongs in t(), and what went wrong technically belongs in a log entry of the subscriber's own.

Worked example

use Drupal\yoyaku\Entity\BookingResourceTariff;
use Drupal\yoyaku\Entity\BookingResource;
use Drupal\yoyaku\Entity\BookingSlot;
use Drupal\yoyaku\Entity\BookingSlotTariff;
use Drupal\yoyaku\Exception\CapacityExceededException;

// 1. A resource, one of its slots, and a tariff. The tariff belongs to the
// resource, so it is the same one every week, and a booking names it
// directly. A slot tariff is only needed to override it for one
// slot (its quota or its price here).
$resource = BookingResource::create(['label' => 'Tennis court']);
$resource->save();

$slot = BookingSlot::create([
  'resource' => $resource->id(),
  'start' => strtotime('2026-07-01 10:00'),
  'end' => strtotime('2026-07-01 11:00'),
]);
$slot->save();

$tariff = BookingResourceTariff::create([
  'resource' => $resource->id(),
  'key' => 'default',
  'label' => 'Court time',
]);
$tariff->save();

$slot_tariff = BookingSlotTariff::create([
  'slot' => $slot->id(),
  'tariff' => $tariff->id(),
  'capacity' => 1,
]);
$slot_tariff->save();

// 2. Hold, then confirm after payment succeeds. The hold composes the quantity
// into units and takes them as one order, so this reads the same whether the
// resource takes one booking of N or N bookings on N seats.
try {
  $held = \Drupal::service('yoyaku.booking_manager')
    ->hold($slot, $tariff, 1, reference: 'order:123');
}
catch (CapacityExceededException) {
  // Show "no longer available".
}

// … payment callback …
\Drupal::service('yoyaku.transaction_manager')->confirmTransaction($held->transaction);

Entity accessors

Each entity exposes typed getters, e.g.:

  • Resource: isActive(), getCapacity(), getLeadTime(), getLeadTimeUnit(), getType().
  • Slot: getResource(), getStart(), getEnd(), getCapacity(), isPublished(), getRule(), getBookableUntil().
  • Tariff (the resource's tariff, which a booking names): getResource(), getKey(), getCapacity().
  • SlotTariff (that tariff's override for one slot): getSlot(), getTariff(), getCapacity().
  • Booking: getQuantity(), getState(), getHoldExpires(), getSlotId(), getTariffId(), getTransactionId(), isHeld(), isConsuming().
  • Transaction (the order a group was held in): getState(), getReference().

All carry getTenantId().