Skip to content

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.