Constraints¶
A booking constraint vets a hold request before any capacity is taken (booking window, blackout dates, per-subject limits…). Constraints are plugins, so sites and submodules add their own without touching the engine.
flowchart LR
H[hold] --> CM{Constraint manager}
CM --> C1[booking_window]
CM --> C3[slot_opening]
CM --> C2[your constraint]
C1 -->|ok| CM
C3 -->|ok| CM
C2 -->|violation reason| FAIL[BookingException]
CM -->|all pass| EV[HOLD_REQUESTED event] --> CAP[Capacity check]
Every registered constraint is consulted on every hold; the first to
return a non-NULL reason aborts the hold with a BookingException.
The reason is what the booker reads, word for word, beside the offer they were
refused. So it belongs in t(), and it names nothing they cannot see on the
page in front of them: the shipped constraints answer in whole sentences and
leave the plugin id and the entity ids to the log entry the refusal carries with
it. See Exceptions.
Where one refusal reaches two different people, it says two different things.
A seat that has gone is reported as the seat to a booker who pointed at it on
the plan, and as the room to a booker who asked for a number: the seat the
second one would have had is the engine's own choice, made after they asked, and
it appears nowhere on their screen. PlaceBookable tells them apart by
place_chosen, which the engine sets to FALSE on every booking it composes.
Anatomy¶
- Attribute:
#[\Drupal\yoyaku\Attribute\BookingConstraint(id: '…', label: …)] - Interface:
\Drupal\yoyaku\BookingConstraintInterface - Base class:
\Drupal\yoyaku\BookingConstraintBase - Plugins live in
Plugin/BookingConstraint/ - Manager service:
yoyaku.constraint_manager
public function check(
BookingAskInterface $booking,
?string $subject,
): ?string;
$booking is the ask being made: its slot, the tariff pricing it, how
many units it wants, and the fields a consumer attached. The ask rather than
its parts, because they were only ever passed together.
Return NULL if satisfied, or a human-readable reason to reject.
Built-in: booking_window¶
Replicates the legacy "off limit" rule: a slot cannot be booked unless it
starts at least the resource's lead_time (hours/days) from now. It is a
no-op when lead_time is 0.
Built-in: slot_opening¶
Gates booking on a slot's manual opening time. A slot may carry an
opens_at timestamp; until that moment passes, holds are refused. This models
the real-world pattern where a whole month is released for booking at a fixed
wall-clock instant (e.g. "June opens on May 4th at 2pm") - a deliberate
thundering herd. An empty opens_at means booking is open immediately.
opens_at is set per slot and is batch-settable: the Generate slots admin
form stamps one opening moment on a whole range as it creates them, and the
Bulk update slots form (filter by resource and date, then set/clear the
opening, status, published flag or overall capacity) changes it afterwards over
many slots at once - so opening, moving or closing a month is a single action.
Correctness under the herd is guaranteed by the engine's row lock; this
constraint just keeps the gate shut until the bell.
Writing a constraint¶
namespace Drupal\my_module\Plugin\BookingConstraint;
use Drupal\yoyaku\BookingAskInterface;
use Drupal\yoyaku\Attribute\BookingConstraint;
use Drupal\yoyaku\BookingConstraintBase;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
#[BookingConstraint(
id: 'one_per_subject',
label: new TranslatableMarkup('One booking per subject'),
)]
final class OnePerSubject extends BookingConstraintBase {
use StringTranslationTrait;
public function check(
BookingAskInterface $booking,
?string $subject,
): ?string {
if ($subject === NULL) {
return NULL;
}
// …look up existing bookings for $subject on this slot…
$already = FALSE;
return $already ? (string) $this->t('You already have a booking for this slot.') : NULL;
}
}
Need a service (entity query, current user, …)? Implement
ContainerFactoryPluginInterface and inject it in create(), exactly like the
built-in BookingWindow constraint does for the time service.
A constraint that reads, on a group¶
check() is asked once per ask, and a placed venue composes one ask per seat,
so a constraint that reads a row of its own reads it once per seat. A party of
twelve asked the same question twelve times above the slot locks and twelve
times under them, which is what #3616341 was about.
The answer is a static cache, and the engine owns its boundaries. Implement
BookingGroupConstraintInterface and the engine calls three things:
preload($asks), once above the locks and again under them, before any ask is checked. This is for reads whose shape depends on knowing the whole group, such as loading every place it names in one go. A read that is simply the same question per ask needs nothing here: cache it statically, and the first one asks it for everybody.addTakenBooking($booking), as each booking is saved, so the group's own effect is counted without going back to the table. Skip this and a group naming one place twice passes twice, each ask checked against a snapshot taken before either was written.reset(), when the locks are taken and again when the hold ends.
The first reset() is the important one. Nothing read while the slots were
still moving may answer the pass that stops them moving, which is the race that
pass exists to close: two requests each reading room for the last unit would
both commit. Because the static cache is dropped there whether or not anything primed
it, a constraint keeps no notion of having preloaded anything, and a caller that
is not the engine is as correct as one that is.
Prefer caching statically where the answer belongs over caching statically inside the plugin.
PlaceBookable keeps nothing of its own: it asks PlaceAvailability which
places the slot has taken, which is the same read the seating strategy and
the orphaned_places policy are answered from, so one read of the slot
settles all three. Shaped as "the counts of the places this group names" it
would have settled one.
Constraint policies¶
A constraint is an invariant of the model that nobody switches off. A rule a site chooses, configures and attaches where it should reach is a constraint policy, a separate plugin type with its own manager and its own lifecycle.
See Constraint policies.