Skip to content

Booking channels

A booking channel is a front that bookings are made through: the main site, a mini-site for one special event on its own domain, a partner integration, and in time a box office. Naming one lets a tenant hold part of what it offers back to one front, so a tariff meant for a gala mini-site is neither shown nor holdable anywhere else.

A site with one front needs none of this. Every tenant has a default channel, {tenant}.default, that serves every request no other channel claims, and what names no channel is offered everywhere, the default channel included, so an install that never touches the feature behaves exactly as it did before there was one.

flowchart LR
    REQ[Request] --> SCC[Booking channel context]
    SCC --> R1[domain resolver]
    SCC --> R2[your resolver]
    R1 -->|channel id| SCC
    SCC -->|first non-empty wins,<br/>else the tenant's default| CH[(Channel being served)]
    CH --> OFFER[What is on offer here]
    CH --> ORDER[Stamped on the order it opens]

How it works

  • BookingChannel is a config entity (yoyaku_booking_channel). It belongs to exactly one tenant: mini-sites are a tenant's own, so unlike a resource type a channel is never shared between tenants. The tenant is fixed once the channel exists. Its id is the tenant's machine name and its own, joined by a dot (bordeaux.box_office), so every tenant can have its own box_office, web or partner channel.
  • The booking channel context (yoyaku.booking_channel_context, BookingChannelContextInterface) answers which channel is being served, by consulting resolvers tagged yoyaku.booking_channel_resolver in priority order. It is one service with the tenant context (TenantAndBookingChannelContext), since a channel's tenant follows it.
  • Every tenant has a default channel, {tenant}.default, created when the tenant is saved and deleted with it; it cannot be deleted on its own. A request no resolver claims is served through it: the main site, the back office, the API, drush and cron. It is offered what names no channel, as every channel is, so a tariff restricted to a mini-site does not have to name it to stay off the main site's list, and naming it restricts an offer to the main site.
  • BookingChannelField::getDefinition() provides the shared booking_channels base field, carried by the resource and the tariff. Naming no channel offers it everywhere; naming channels offers it only where one of them is served.
  • BookingChannelFilter (yoyaku.booking_channel_filter) is the one place that rule is written. Everything that reads offers asks it, and so does the booking path.

What a channel restricts

Named on Effect
A resource Its slots are listed and bookable on that front only. The public page for one of its slots is not served elsewhere. This is how a mini-site for a single event is built.
A tariff The tariff is absent from every offer list off its front, and refused if asked for anyway. The slot itself stays available, with its other tariffs.

A slot carries no channels of its own: it inherits its resource's.

Hiding is not enforcing

Both halves ship, and neither stands in for the other.

  • The reads. BookingAvailability::getTariffsByResource() narrows the read the availability map is built from, so the booking form, the calendar and the API stop offering a withheld tariff together. The place map reads its own tariffs and narrows them through the same BookingChannelFilter, which also settles what an area may be booked at and which allowances are published, since all three come off that one read. The public routes that name a slot or a resource carry a _yoyaku_booking_channel requirement, so a page for a withdrawn resource is refused rather than rendered and then refused on submitting.
  • What is already held is not narrowed. A tariff withdrawn while somebody holds a booking at it stays on the page and on the plan, at what they hold and no more, saying that it has been withdrawn. It is not put back on offer: the booking page joins it in per slot, and the plan carries it with the basket rather than with the offers, which are shared between every visitor on the front. A booker who cannot see a booking cannot give it back, and it is still on their cart.
  • The hold. The engine refuses an ask naming a resource or a tariff the front does not offer, in BookingManager::hold() beside the rest of what a ask has to be. A list leaving a tariff out is a courtesy to the visitor; the refusal is what stops a crafted request, or a page left open while an operator withdrew the tariff, from holding it anyway. Asked there and not by the surfaces, because that is the layer every caller passes through: a surface that composes its own asks and hands them to the engine reaches capacity without going through anything above it.
  • The front a hold is judged on is the order's, recorded when it was opened, not the one being served at the moment a booking is added. A queued hold, a retry, and an operator finishing a booker's order are none of them standing in the front that order belongs to. An order being opened has nothing recorded yet, and there the front being served is the answer, which is the same value it is about to be stamped with.

The back office keeps seeing everything the tenant offers, restricted or not. That is why the rule is a route requirement on a handful of public routes rather than part of YoyakuAccessControlHandler, which lets an administrator reach every record.

The order records the front it came in by

A transaction carries a booking_channel, stamped once as the order is opened and read from there afterwards. The request only ever seeds it: a basket outlives the request that opened it, a booker who arrived on a mini-site can come back on the main site, and the back office and the notifications render in a context of their own. Reading the ambient channel later would answer with whichever front happened to be serving. Every order records one: an order opened where no other channel answered records its tenant's default channel, so anything built for an order later knows which front its booker used.

It is also what tells bookings apart per channel in reporting.

Placing an order for someone else

An operator placing an order on a booker's behalf (Place a booking, at the box office or over the phone) places it in a channel. The form lists the tenant's channels the operator may place orders in, starting on the default one, offers what the chosen channel offers, and opens the order in it, so the order records it and the engine judges every booking on it by that channel.

An administrator may place orders in every channel, and with yoyaku_order so may whoever manages the tenant's orders. There is no permission per channel, which would take a role per channel to give one clerk one channel. With yoyaku_manager, a channel's Operators tab grants it to a person instead (see Channel operators); any other module granting order work its own way widens the list with hook_yoyaku_channels_to_place_orders_in_alter().

The order records who placed it (placed_by, empty when the booker placed it themselves), and the orders list filters by channel and by that operator, so a box office's day can be reconciled: the orders it took, and who took each.

The engine refuses an order opened in a channel of another tenant, but does not check who opened it: it also runs under cron and queue workers, with no user, so the surface offering the choice offers only what the operator may.

Building for an order in its channel

Whatever is built about an order away from its front (an email sent during payment, by an operator in the back office, by cron) is built in the channel the order records:

$context = \Drupal::service('yoyaku.booking_channel_context');
$context->executeInBookingChannel($order->getBookingChannelId(), function () use ($order) {
  // Here the channel context answers the order's channel, and the tenant
  // context that channel's tenant.
});

Both are restored on return, even when the callback throws. With yoyaku_domain, entering a channel also enters its primary domain, so what is built carries that front's links and site name. A channel id naming nothing is logged and the callback runs where it is.

One channel per domain

The Yoyaku Domain submodule (yoyaku_domain) is the shipped answer, and the one a mini-site needs: a channel names the domains it is served on, on its Domains tab, and a visitor arriving on one of them is offered what names that channel plus everything that names none.

  • The binding lives on the channel, not on the domain record, the same arrangement as the tenant binding described in Multi-tenancy. Several domains may share one channel.
  • One domain belongs to one channel, and to the tenant that channel belongs to. Validation refuses both a domain another channel already holds and a domain served in another tenant, so the two bindings cannot contradict each other.
  • A domain no channel claims defers, so the main site of a site whose only channels are its mini-sites is served through the tenant's default channel, which offers everything unrestricted.
  • Which domain is being served is Domain's answer. A hostname it cannot match, as on the CLI and in cron, falls back to the default domain record, so such a run is served through whatever channel names that record.

Caching

Offers depend on the front, so anything showing them varies on the yoyaku_booking_channel cache context. It ships on the slot booking form, the calendar element, the upcoming-slots formatter, the channel access check and the Views filter. The context is invalidated by the channel list cache tags, so creating a channel or binding a domain to one drops pages cached before it.

url.site is not a substitute: a resolver may name the front from an API consumer or an operator screen on the same host.

Listing with Views

yoyaku_views adds a filter, Offered on the booking channel being served, to resources and tariffs. It takes no setting: rows are kept when they name no channel, or name the one being served. A view of slots adds the resource relationship and filters there.

To name one channel explicitly instead, filter on the Booking channels field, which Views exposes like any other.

Adding a resolver

Any other way of deciding the front is a service away: an API consumer, a path prefix, an operator screen.

# my_module.services.yml
services:
  my_module.consumer_channel_resolver:
    class: Drupal\my_module\ConsumerChannelResolver
    arguments: ['@my_module.consumer_context']
    tags:
      - { name: yoyaku.booking_channel_resolver, priority: 10 }
final class ConsumerChannelResolver implements BookingChannelResolverInterface {

  public function resolveBookingChannelId(): ?string {
    // Return a channel id ({tenant}.{name}), or NULL to defer to the next
    // resolver.
    return $this->consumerContext->current()?->getChannelId();
  }

}

Return NULL to defer. A channel of another tenant than the one being served is skipped, and so is an id naming no channel at all, so a binding that outlives its channel serves the request through the tenant's default channel rather than refusing it.

What else hangs off a channel

A channel is where a front's own rules live, so other modules attach their configuration to it as third-party settings rather than adding fields to it:

  • yoyaku_domain records the domains the channel is served on.
  • yoyaku_booking_fee records what the channel charges on top of a booking, and what it calls those fees. See Booking fees.