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.
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.yoyaku_constraint_manager
public function check(
BookingCategoryInterface $category,
int $quantity,
?string $subject,
): ?string;
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\Attribute\BookingConstraint;
use Drupal\yoyaku\BookingCategoryInterface;
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(BookingCategoryInterface $category, int $quantity, ?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.
Order constraint policies¶
A booking constraint runs at hold time and knows nothing about the
customer. Some rules only make sense once the order carries an identity (an
email), across a whole order and the customer's other orders: "one booking per
person per slot", "one refuge among all refuges at once". Those are order
constraints, a second plugin type in the yoyaku_order module:
- Attribute:
#[\Drupal\yoyaku_order\Attribute\OrderConstraint(id: '…', label: …)] - Interface:
\Drupal\yoyaku_order\OrderConstraintInterface - Base class:
\Drupal\yoyaku_order\OrderConstraintBase - Plugins live in
Plugin/OrderConstraint/
They run when the order is validated or confirmed (OrderConstraintManager),
and always count within the order's tenant.
Configurable, attachable policies¶
An order constraint is a configurable policy: it carries settings and renders its own settings form. You attach one or more policies to a resource or a resource type on the entity's Policies tab, rather than toggling a field per rule. A type's policies apply to every resource of the type; a resource adds its own on top. So a rule is never a bespoke checkbox, it is a policy you add and configure where it applies.
Storage is on the host: a resource keeps its policies in a field, a type in its
third_party_settings; ConstraintPolicyResolver reads and merges them.
The two shipped policies:
| Policy | Settings | Rule |
|---|---|---|
| Per-booker booking limit per slot | max |
At most max active bookings per booker for a single slot of the resource (1 = one per person, higher suits ticketing). |
| Per-booker limit across an exclusivity group | group, max |
At most max active bookings per booker across every resource sharing the group name. group is a plain string: competing resources share it, an exception omits it. |
So "one refuge among all refuges" is the group policy with group = refuges
and max = 1, attached to the refuge type; a refuge that may be booked
alongside the others simply does not carry the group.
Order attributes (the fields a policy counts by)¶
A limit is always "at most N per something". That something is not fixed to the booker: each policy carries a Count by setting naming one or more order attributes, the dimensions two orders must share to count together. The default is the booker email.
An order attribute is simply a field on the order (yoyaku_transaction)
entity. The order ships two, booker_email and booker_name, and an
administrator adds more from Manage fields on the order (a phone, a service,
a birth date, whatever the site needs), each of any field type. Because they are
real fields, they are also columns in Views with no extra code. A matcher is
derived once per attribute field, so a field appears as a Count-by
dimension the moment it exists, with nothing to write.
- Attribute:
#[\Drupal\yoyaku_order\Attribute\OrderAttributeMatcher(id: '…', deriver: …)] - Interface:
\Drupal\yoyaku_order\OrderAttributeMatcherInterface - Deriver:
\Drupal\yoyaku_order\Plugin\Derivative\OrderFieldMatcherDeriver - Manager service:
yoyaku_order.attribute_matcher_manager
A matcher answers, from the order's OrderContext (the order plus what the
tagged context builders resolve from it, its payment and so on):
public function value(OrderContext $context): ?string;
public function match(QueryInterface $query, OrderContext $context): void;
public function capture(OrderContext $context, string $value): void;
value() is the order's value on the dimension, or NULL when it is unknown
(no email captured yet, no card stored). A NULL on any configured matcher
means the identity is incomplete, so the policy is skipped rather than
counting every order together. When every value is known, each matcher's
match() narrows the count query, and a policy counting by several dimensions
counts two orders together only when they match on all of them (AND). To
express "same email or same card", attach two policies. capture() is the
symmetric write, used by the booking form (see below).
Shipped matchers:
| Matcher | Backed by | Counts orders sharing |
|---|---|---|
order_field:booker_email |
the booker_email field |
the booker email |
order_field:booker_name |
the booker_name field |
the booker name |
order_field:field_* |
any field an administrator adds | that field's value |
card_token |
the stored payment (yoyaku_payment) |
the stored payment card |
The card_token matcher resists a booker re-registering under a fresh email to
slip past a per-booker limit: the two orders still share the stored card. It is
captured at the payment step, not the form, so it is match-only.
Capturing attributes from the booking form¶
The booking form step maps submission elements onto order attributes: on the
handler you choose, per element, which attribute it feeds. On submit the
attribute's matcher capture()s the value onto the order, through the field
API, so a shipped field and an administrator's field behave identically. When
the handler's enforce option is on, the policies are then validated over every
captured dimension, blocking a submission that could never be confirmed.
A composite element is expanded into one mapping source per sub-element,
keyed <element>__<sub> (webform's own composite key convention; a dot is not
used because config keys may not contain dots), so its parts feed separate order
fields, for example address__city onto a city field and address__postal_code
onto a postcode field.