Skip to content

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.

  • hidden removes the widget with #access, which also makes core ignore a value posted for it anyway. Hiding by CSS or by #disabled would leave the posted value standing.
  • read_only replaces 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 .module file (D11.3+, D12-ready), matching the Orchestra house style.
  • Attribute-based entities (#[ContentEntityType], #[ConfigEntityType]), final classes, 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 its info.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 of yoyaku_orchestra, yoyaku_ticket_placement sits under the ticket. ModuleBoundariesTest checks 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, so toUrl() 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/orders was 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 the languages:language_interface context; 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 weight an operator sets is listed through WeightedEntityListBuilder, 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.