Architecture¶
Yoyaku is a headless, API-first core with a family of optional
submodules. The core ships only entities, the BookingManager service,
events and plugin types - no UI, no payment, no contrib dependencies.
Layers¶
flowchart TB
subgraph presentation["Presentation / consumers (submodules & 3rd party)"]
UI[yoyaku_ui<br/>admin]
PRES[yoyaku_presentation<br/>shared widgets]
CAL[yoyaku_calendar<br/>public picker]
WFE[yoyaku_webform<br/>form step]
ORCH[yoyaku_orchestra<br/>workflow tasks]
PAY[yoyaku_payment<br/>price / guarantee]
API[yoyaku_api<br/>client contract]
end
subgraph core["yoyaku (core, headless)"]
BM[BookingManager service]
EV[Lifecycle events]
CON[BookingConstraint plugins]
QW[yoyaku_expire_transaction_holds<br/>queue worker + cron]
end
subgraph data["Entities"]
R[Resource] --> S[Slot] --> C[Tariff] --> B[Booking]
end
presentation --> BM
BM --> CON
BM --> data
BM --> EV
EV --> presentation
QW --> BM
The dependency direction is strict: consumers depend on the core; the core depends on nothing but Drupal. The engine never knows about webforms, payments, nodes or workflows - they react through the events and drive it through the API.
The booking on a resource page with a calendar is one surface, not the model. For the full space of booking use cases (search, appointments, events, carts, API), the seams that absorb each one, and the rule that the core never assumes a surface, see Use cases and surfaces.
Module map¶
| Module | Role | Status |
|---|---|---|
yoyaku |
Core: entities, BookingManager, TransactionManager, events, constraint plugin type, hold-expiry worker, multi-tenancy. |
Done |
yoyaku_ui |
Availability/slot/booking admin, generation and bulk tools, dashboards. | Done |
yoyaku_presentation |
What the booking surfaces share: the card one offer is drawn as, the quantity stepper, their styles and accessibility rules, and the queue that posts a run of clicks as one request. | Done |
yoyaku_calendar |
Public JS slot-picker (block / field formatter) over an availability endpoint, and the per-slot booking page (yoyaku_calendar_interaction parks a booker on that page as a workflow step). |
Done |
yoyaku_placement |
Numbered or pooled places: venues, sections, place grades, the graphical place map and the configurations that say how a hall opens (yoyaku_placement_io imports and exports a venue package; yoyaku_placement_example ships a runnable venue). |
Done |
yoyaku_cart |
The cross-surface basket and checkout: one basket and one checkout whatever surface filled it. | Done |
yoyaku_node |
Bind a resource to a node with a calendar field formatter. | Done |
yoyaku_manager |
Per-resource managers and scoped access (yoyaku_manager_views adds Views). |
Done |
yoyaku_domain |
Tenant and booking channel resolvers mapping the domain being served to whichever names it, with a Domains tab per tenant and per channel. | Done |
yoyaku_order |
Order (transaction) decorator, order admin, order attributes, the per-booker constraint policies, and the booker emails as OrderNotificationEvent (yoyaku_order_easy_email sends them through Easy Email templates; yoyaku_order_views adds Views). |
Done |
yoyaku_payment |
Price, guarantee and payment, gateway plugin type, "confirm on payment" contract (yoyaku_booking_fee adds a per-ticket fee owed by the channel the booker came in by; yoyaku_payment_views adds Views). |
Done |
yoyaku_ticket |
PDF tickets with a verifiable QR code, through a PDF renderer plugin type (yoyaku_checkin scans one at the door; yoyaku_ticket_placement names the place on a placed ticket). |
Done |
yoyaku_orchestra |
Orchestra bridge: run a booking as a workflow. Every Orchestra integration is a submodule of it, so the whole of it lifts out in one move: yoyaku_orchestra_order (order-review interaction and order actions), yoyaku_orchestra_payment (the guarantee and no-show actions, and the checkout gate), yoyaku_orchestra_cart (starts the workflow when a cart is checked out), yoyaku_orchestra_manager (offers a step to a resource's managers) and yoyaku_orchestra_example (a runnable example). |
Done |
yoyaku_webform |
Bind a Webform submission to a booking transaction. | Done |
yoyaku_views |
Views integration for the core booking entities. | Done |
yoyaku_api |
Booking client API (a local in-process client today; remote HTTP is planned). | Done |
yoyaku_recur |
Recurrence rules that materialize slots. | Planned |
Services¶
The core service ids are namespaced yoyaku.*; each has a typed-interface alias
so a consumer can inject the interface.
| Service id | Class | Purpose |
|---|---|---|
yoyaku.booking_manager |
BookingManager |
The capacity engine and single-booking lifecycle API. |
yoyaku.transaction_lookup |
TransactionLookup |
Every read of what an order holds: which bookings it holds, which it only records, all of them, their count, whether they are all past. Depends on storage and a clock, deliberately: see below. |
yoyaku.transaction_manager |
TransactionManager |
Group transitions across everything an order holds, all or nothing. |
yoyaku.constraint_manager |
BookingConstraintManager |
Discovers and runs booking constraint plugins. |
yoyaku.tenant_and_booking_channel_context |
TenantAndBookingChannelContext |
Resolves the active tenant and the booking channel being served, and enters a channel with executeInBookingChannel(). Injected through its two aliases, yoyaku.tenant_context (TenantContextInterface) and yoyaku.booking_channel_context (BookingChannelContextInterface). |
yoyaku.booking_channel_filter |
BookingChannelFilter |
Decides what that front has on offer, for the reads and the hold alike. |
yoyaku.resource_type_repository |
ResourceTypeRepository |
The tenant-scoped resource types available for reference. |
yoyaku.booking_schedule_resolver |
BookingScheduleResolver |
Resolves a booking's slot timing for scheduling. |
yoyaku.transaction_summary |
TransactionSummary |
Builds a transaction's slots-and-tickets data and renders it for screen, mail and text. |
The module logs on one channel, yoyaku, for every submodule. There is no
channel service: a class that logs injects @logger.factory and uses
YoyakuLoggerTrait::getYoyakuLogger(), so the channel is built only on a
request that writes to it.
The BookingManager is injected with the entity type manager, the database
connection (for transactions + row locks), the event dispatcher, the time
service, the constraint manager, and the booking channel context and filter (see
Booking channels).
Reading an order is split from writing it¶
TransactionLookup exists because reading what an order holds must not depend
on the code that writes it. The write path is a chain: BookingManager is
constructed with ConstraintPolicyManager, and TransactionManager is
constructed with BookingManager. So any service that both writes orders and
answers "what does this order hold" is unreachable from inside that chain,
and the constraint manager, which asks that question on every hold, sits
squarely inside it. Injecting TransactionManager there is a circular service
reference the container refuses.
For a long time each caller worked around that by answering the question itself: the same query appeared in four places, twice as byte-identical private methods in different modules, and the same predicate as an in-PHP filter in four more. That is what let the amount a gateway charged and the amount a summary showed be computed by two separate traversals of one order, and disagree (#3615260).
So the read is its own service, depending on entity storage and a clock and nothing else. Nothing can cycle through it, which is what lets every reader simply ask, including the ones the write path constructs.
It owns every read of an order's bookings: getConsumingBookings(),
getCanceledBookings(), getBookings(), getCurrentBookingCounts() and
isEveryBookingPast(). TransactionManager is therefore write-only by
construction: it transitions an order's bookings and keeps its state in sync,
and it asks the lookup when it needs to read them.
isEveryBookingPast() is why the clock is there. The predicate itself lives on
the booking, as Booking::isPast($now) beside isConsuming() and isHeld(),
because it only ever reads the booking's own slot; keeping it on the manager
would drag the write path into a read. It takes the moment as an argument so a
large order reads the clock once rather than once per booking.
Do not give TransactionLookup a dependency that writes, or a dependency
that itself depends on the write path. That is the whole point of it, and the
cycle comes straight back.
It also makes the engine faster, because the state lives in the query rather than in a filter after the load: picking seats leaves a long tail of released and expired holds on an order, and one production order carried 302 bookings for a single consuming one. Filtering after the load would hydrate all 302 entities, and their field tables, to keep one.
The transaction summary¶
TransactionSummary is the one view of a booking transaction every surface
shows: the cart, the order pages, the cancel screen, check-in, the Orchestra
review and cancel steps, the payment landing, and the notification mails.
build() returns plain data (one card per slot, a row per ticket, a total);
renderArray(), renderHtml() and renderText() turn that one data set into a
screen render array, a rich-mail HTML fragment and a plain-text section.
The rendering is themeable. The screen output goes through nested theme hooks, so a theme can override just the part it needs:
yoyaku_summary(yoyaku-summary.html.twig) - the wrapper and total;yoyaku_summary_slot(yoyaku-summary-slot.html.twig) - one slot card;yoyaku_summary_ticket(yoyaku-summary-ticket.html.twig) - one ticket row.
They are render elements, so a surface's own controls (the cart's Remove buttons) stay real form elements the form builder still processes; the templates only place them.
A slot card has two such regions, and they are not the same thing.
element.actions holds what a booker may do to that slot, the cart's
remove-slot button, and it sits in the card's header row. element.fields holds
what they must answer about it before the order goes on, which the cart fills
from the constraint
policies attached to that
slot's resource, and it renders below the card and outside it: a question is
about the slot without being part of the listing of what the slot holds, and in
the header it lands in a flex row between the slot's details and its button.
A theme overriding yoyaku-summary-slot.html.twig has to print
element.fields: dropping it does not merely lose a control, it removes the
thing standing between a booker and checkout, and the button it gates would come
back to life. The mail output has its own yoyaku_summary_mail
(yoyaku-summary-mail.html.twig), kept separate and styled inline because a
mail client ignores a stylesheet; a theme that overrides it should keep the
styles inline.
What a ticket or slot card shows is still not the summary's to decide: the core
templates stay place-agnostic, and an owning module names its own bookings
through hook_yoyaku_summary_ticket_alter() and
hook_yoyaku_summary_slot_alter() (yoyaku_placement names a place and adds a
re-placement link, say).
A ticket shows only what its booking actually has. One on a slot that offers no
tariffs names none, so its tariff is NULL and every renderer leaves the tariff
out rather than printing a stand-in word; the same goes for a booking no module
labeled, which shows its quantity instead of a named place.
Pricing stays out of the core too. build() leaves every ticket price and
the total empty and invokes hook_yoyaku_summary_alter(); yoyaku_payment fills
them from the payment policy (the price the cart charges) in the configured
currency. So the core never touches money or currency, the same seam pattern
the placement price columns use.
yoyaku_order frames the shared summary with the order's own context through its
own yoyaku_order_summary theme hook (yoyaku-order-summary.html.twig): the
booker and the order state above the bookings. A theme can override that
framing and the nested booking hooks independently.
Theme suggestions¶
Overriding a template does not have to be all or nothing. Each hook offers suggestions, so a site can replace it for one tenant, one surface or one resource and leave the rest alone:
| Hook | Suggestions |
|---|---|
yoyaku_summary |
__tenant_X, __context_Y, and both |
yoyaku_summary_slot, yoyaku_summary_ticket |
__type_X, __resource_N, and both |
yoyaku_summary_mail |
__tenant_X |
yoyaku_ticket |
__tenant_X, __type_Y, __resource_N, and all three |
yoyaku_ticket_document |
__tenant_X |
So yoyaku-summary-ticket--resource-12.html.twig restyles the rows of one
resource, and yoyaku-ticket--type-sailing.html.twig prints a different ticket
for every sailing resource. Each suggestion names its dimension as well as its
value, so a tenant and a resource type that happen to share a name can never be
confused, and the combination of every dimension comes last, which is the one
the theme system prefers when a site provides it.
The context is whatever the calling surface names itself: the cart passes
cart, an order page order, check-in checkin, the cancel screens cancel.
A new surface passes its own name to renderArray() and immediately has a
suggestion, with nothing to register.
Two rules bound this. A suggestion may only name a dimension an administrator
authors and that is bounded: the tenant, the resource, its type, the surface.
Never anything a visitor generates (an order, a booking, a booker), which would
mean a new suggestion string for every order ever placed. And a suggestion
belongs to the module owning the dimension: yoyaku_ticket offers its own,
core offers the tenant and resource ones, and a module that knows a dimension
core does not adds it through hook_theme_suggestions_HOOK_alter().
Field rules¶
Every resource form carries every field the engine and its submodules can offer, whether or not the site does that thing. A house that never charges a no-show fee answers about one on every resource; a site whose slots are always single days still answers about ranges. A field rule is one level's answer about one resource field, so nobody downstream is asked again.
Its own screen at each level, reached as a Field rules tab. See field rules for the operator's view; what follows is how it resolves.
A rule says two things about a field, and they answer different questions. The value answers every resource that says it inherits the field; the display says who may answer it on a form.
| Display | Means |
|---|---|
shown |
Editable. Answers a resource that inherits, and stops the walk. |
hidden |
Not asked. What applies comes from the level that hid it. |
read_only |
Shown with the value and the level it came from, not editable. |
Turning a feature off is not a separate idea: it is hidden with an empty
value. A house rule is read_only with the value. Leaving a field alone is
saying nothing at all, which is not the same as shown.
The chain¶
FieldRuleResolver walks resource type, then tenant, then site, and the
nearest level that decides wins. A level that answers shown decides too: it
stops the walk, which is how a tenant reopens what the site closed.
A tariff preset belongs to its tenant and may be applied to a production of any type, so it asks with no type at all, and a rule made for one resource type never reaches its cells.
The row says where its answer comes from¶
The resource's row is still the only thing anybody reads. One of the values it
can hold is the instruction to ask the level above: inherit, a real stored
value, offered as a real choice, validated like any other, and the field's
default so a resource starts out following its levels.
On EXACTLY the fields whose readers go through getEffectiveValue(), each declared by
the module that adds it with RuledField::FOLLOWS on the definition it builds.
The two halves move together, and either one alone is a bug. A field declaring
it whose readers still compare the row hands them the literal string inherit,
which matches none of the answers they know: a calendar asked to book "inherit"
days, a payment asked to collect "inherit" of a price. A reader asking
getEffectiveValue() for a field that declares nothing has nothing to follow, so the
levels never reach it and only a resource created after the rule was made ever
sees its value. Elsewhere an empty value says the same thing,
which is what a number or a duration has to use anyway.
FieldRuleResolver::getEffectiveValue() is the one place that follows it, and every
reader asks it rather than walking the chain itself. So a level changing its
mind reaches every resource still saying it inherits, and reaches no resource
that answered for itself. Nothing is copied, so nothing goes stale.
A few fields are copied as a resource is created instead, and say so with
RuledField::COPIED_ON_CREATE: a venue, a configuration, a pinning, a capacity,
and the money.
Other rows depend on those. A price that moved because somebody edited a site
rule would reprice what has already been booked, and a venue that moved would
leave a hall's seats in a building it is no longer in.
A rule that would overwrite does not apply¶
Where a resource already holds a value different from what the rule says it should be, the rule is not applied at all: the field stays on the form, with its value, and the operator settles it. A rule only takes a field away where there is nothing to lose - the resource says it inherits, or already holds what the rule says.
That includes a rule enforcing no value. "We do not do this here", over a resource that already does it, is a disagreement like any other. Taking the field away there would leave the value it holds applying with nothing on screen to say so, which is the bug this mechanism exists to end, arrived at from the other direction.
So a rule never destroys an answer, and the two aims hold together: what a level decides applies, and a resource form stops asking forty questions whose answers were decided elsewhere.
Declaring one¶
Every question a resource form asks is open to a rule: thirty-six of them, the engine's own plus payment's, placement's, the calendar's and ticketing's. They are read off the resource's own field definitions rather than listed, so a field a module adds is open to one the day it is added. Excluded, and only these: the label and the type, which are what a resource IS rather than how it behaves; the active flag, the switch an operator reaches for most; and the interface language. The tenant needs no exclusion, being what decides which rule reaches a resource in the first place.
The label, whether an answer is required, and the values a level may enforce all
come from the field, so a settings screen cannot offer a choice the field would
reject. Hiding a required field with no value in its place is refused where the
rule is made, rather than producing a form nobody can submit - unless the field
answers for itself, one carrying a default (inherit among them) arriving
answered whatever a level does.
A module may still declare a field on another entity type, or override one of
these, with hook_yoyaku_field_rule_info(). A declaration merged over an
existing one keeps what it does not restate.
The values a level may enforce come from the field's own definition, not from a second list in the declaration, so a rule cannot enforce a value the field would reject.
Making a rule¶
The site, a tenant and a resource type get the same screen, from one place: a Field rules tab on each, one row per declared field, in a collapsed field group per module that asks them. Its own page rather than a section of the entity form, the way a tenant's domains and a type's policies are: thirty-six rules in ten sections, nested inside the three questions a resource type asks about itself, buries those three.
Grouped rather than flat because thirty-six rules in one list is unreadable, and
ordered inside each group by that module's own form weight: weight alone reads as
scrambled, since each module numbers from scratch. A row says how the field is
asked and what value applies, and saying nothing is distinct from saying shown -
the first falls through to the level above, the second reopens what a wider level
closed.
refuse() runs in the form's validation, so a rule that cannot be made is
refused against the row that made it.
Where a rule shows¶
The rule reaches every form the field appears on, through the widget rather than through each form, so a field added to a screen nobody has thought of yet is ruled there too.
hiddenremoves the widget with#access, which also makes core ignore a value posted for it anyway. Hiding by CSS or by#disabledwould leave the posted value standing.read_onlyreplaces the widget with the value and the level that set it, in words: "When the slot ends, set on the tenant." Not by color alone, because a grayed box says only that somebody, somewhere, took the field away.
One consequence worth knowing when adding a #states rule to a resource form:
a condition watching a ruled field's input is never met once that field is
ruled away, because the input is not there. The lead-time fields, which watch
the booking cutoff, are gated server-side from the value that applies whenever
the cutoff is not answerable.
Design rules¶
- OOP
#[Hook]only - no.modulefile (D11.3+, D12-ready), matching the Orchestra house style. - Attribute-based entities (
#[ContentEntityType],#[ConfigEntityType]),finalclasses,declare(strict_types=1). - Capacity is the lock target - see Capacity and holds.
- Money and presentation stay out of the core.
- A module names classes only from the modules it depends on. Each
submodule is separable, so a
use Drupal\<other>\...needs<other>in itsinfo.yml, walked transitively. A service id does not:'@?other.service'is a string, so it degrades to NULL on its own and is how an integration stays optional. Optional and typed is what the seam interfaces are for: the lower module owns the interface (PriceResolverInterface,UnitAssignmentProviderInterface,BookingCartInterface), the optional higher module implements it, and the consumer names only the lower one. Where a whole class exists for one integration, its home is a bridge submodule, and a bridge lives under the module it would leave with: every Orchestra one is a submodule ofyoyaku_orchestra,yoyaku_ticket_placementsits under the ticket.ModuleBoundariesTestchecks every module's shipped files against its declared dependencies, so a new import cannot land without its declaration. - Entity links follow the interface language. Every Yoyaku entity uses
InterfaceLanguageUrlTrait, sotoUrl()builds a link for the language the page is being shown in rather than the language the record was created in. Core pins entity links to the entity's own language, which is right for translatable content with a page per language and wrong here: these entities have no page per language, and their links are admin screens. Several of them do carry a translated label (Translation), so the language a row is loaded in is not incidental to what it says; it is still incidental to where its admin screens live, which is what the trait is about. Without it an administrator working at/fr/admin/booking/orderswas sent to an unprefixed path and dropped back into English mid-task. A caller that means a specific language passes['language' => $language]and still wins. A page rendering these links varies by interface language, so cache it with thelanguages:language_interfacecontext; a page showing a translated label varies by content language too. - A weighted list is listed in weight order. A content entity type carrying
a
weightan operator sets is listed throughWeightedEntityListBuilder, which orders by weight, then by the type's label when it declares one, then by id so two rows tying on both come out the same way twice running. Core's default is the id alone, which is the order the rows happened to be created in, so a screen inheriting it left an operator typing a number that changed nothing they could see, while the pickers that build their own query already read the weight. Config entities need none of this:ConfigEntityBase::sort()is weight and then label already.