Rentals for developers
This reference describes the rental model and its integration with Bookings and KahunaCart.
The core classes use constructor injection and do not depend on Grav. kahunacart-rentals.php registers event handlers; classes/KahunaCartRentals.php connects Grav configuration and events to the core.
Before you begin
- An API key or signed-in admin session with
api.accessand the required route permission. See REST API. - Shell access for the CLI, and the worker running for anything on the queue.
The model
A nightly property is a KahunaCart product whose Bookings service rule uses mode nightly. Rentals does not maintain a separate property table.
| The thing | Where it lives |
|---|---|
| The name, the description, the pictures, the price | Core's catalogue |
| Which bookable it occupies, how many of it there are, its hours, its days off, its outside calendars | Bookings |
How many people it sleeps (party_size) |
Bookings' service row |
| Check-in and check-out, the shortest and longest stay, arrival and departure days, the fees, the deposit terms, the cancellation policy | kahunacart_rental_rules |
| The rate card | kahunacart_rental_rates |
Variants represent price-changing choices, such as room only or room with breakfast. Service resolution uses variant over product: an option with its own rule has its own rate card; an option inheriting the product rule shares its card. RateRepository does not resolve the service again.
Bookings integration
Rentals registers a booking mode through onKahunaCartBookingsRegisterModes.
Mode\NightlyMode implements BookingMode as a stateless adapter. It returns an Offer without accessing Bookings tables directly. Bookings reserves the offer through its normal locking, expired-hold sweep, availability recheck and unique-key enforcement.
It also implements two optional things Bookings offers:
Mode\PaymentPolicyprovidesrefundOnCancel()andbalanceOverdueSays(). These return refund guidance and overdue-payment text without moving money.Mode\WholeDaysis a marker interface enabling the Stays view and front desk arrival and departure lists.
A mode never reads Bookings’ tables directly. ModeServices provides eligible properties, capacities, schedules, time off, bookings, holds and external blocks. tests/Unit/PluginWiringTest.php checks that this add-on does not query Bookings tables.
The engine also resolves a policy through ServiceRepository::policyFor($productId, $variantId). This supports callers such as the CLI that begin with a product ID rather than ModeServices.
The engine
Engine\NightlyScheduler provides two query methods and one rescheduling rule:
/** @return list<Night> */
public function nights(int $productId, ?int $variantId, string $fromDate, string $toDate, ?int $excludeBookingId = null): array;
public function nightsFor(array $policy, string $fromDate, string $toDate, ?string $excludeHoldUid = null, ?int $excludeBookingId = null): array;
public function quote(int $productId, ?int $variantId, string $arrivalDate, string $departureDate, int $guests = 1, ?int $excludeBookingId = null): Quote;
public function quoteFor(array $policy, string $arrivalDate, string $departureDate, int $guests = 1, ?string $excludeHoldUid = null, ?int $excludeBookingId = null): Quote;
/** @return array<string, mixed> — night by night, for Troubleshoot */
public function explainFor(array $policy, string $fromDate, string $toDate, ?string $excludeHoldUid = null, ?int $excludeBookingId = null): array;
public function rulesFor(array $policy): StayRules;
public function clockFor(array $policy): NightClock;
public function policyFor(int $productId, ?int $variantId = null): ?array;
public function now(): int;
Engine\NightClock calculates nights by their start date in the property’s timezone. Each night runs from check-in to check-out the next morning. With the default times, this is eighteen hours, leaving six hours for same-day changeovers.
Engine\RateCard calculates prices from supplied bands without database access. The band with the highest position wins for each night. Engine\Occupancy distinguishes single-unit claims from outside-calendar blocks covering all units.
Validation order
guestsmin_nights— the band's minimum, else the property'smax_nights, with 0 meaning no limitarrival_day,departure_day- Per night:
past,notice,window,time-off,blackout,external,session,hold,booked, plus the compositeneed:<label>:<reason> gap
The first failing check supplies the refusal reason.
The reason codes
Rentals registers seven codes with Bookings’ Engine\Reason, used by Troubleshoot, the CLI and the picker. Other codes are inherited from Bookings. The nightly engine does not emit Bookings’ capacity code (Full).
| Code | What it says | Whose |
|---|---|---|
min_nights |
Shorter than the shortest stay here | Rentals |
max_nights |
Longer than the longest stay here | Rentals |
arrival_day |
Stays do not start on that day | Rentals |
departure_day |
Stays do not end on that day | Rentals |
guests |
More guests than this sleeps | Rentals |
gap |
That would leave nights nobody could book | Rentals |
past |
That date has been and gone | Rentals |
notice |
Too soon | Bookings |
window |
Too far ahead | Bookings |
time-off |
Time off | Bookings |
blackout |
Closed | Bookings |
external |
Busy on another calendar — external:Airbnb says which |
Bookings |
session |
Teaching a class | Bookings |
hold |
Held by someone else | Bookings |
booked |
Booked | Bookings |
Engine\RentalReason::ORDER defines reason priority. For gap, Quote::$stranded identifies the unbookable night included in the message. past replaces the corresponding Bookings reason.
Multi-bookable requirements prefix the reason with their label: need:Sea-view rooms:booked displays “Sea-view rooms: booked”. Single-bookable requirements return the unprefixed reason.
The five night states
| State | The night before | This night | What a guest may do with the date |
|---|---|---|---|
free |
free | free | arrive or leave |
arrival_only |
taken | free | arrive |
departure_only |
free | taken | leave |
taken |
taken | taken | No arrival or departure available |
blocked |
taken | taken | nothing — the property is shut |
blocked represents a taken night caused by blackout or time-off, and applies only when the previous night is also taken. The first closure date therefore returns departure_only with reason blackout.
Quote response
Public availability includes Engine\Quote::toArray() under mode.quote. The picker uses this response for its summary. Amounts are in minor units of currency.
| Key | What it is |
|---|---|
ok |
Whether these nights can be had at all; everything below is only worth reading when it is true |
reason, says, stranded |
Why not, the sentence for it, and the night the gap rule named |
arrival, departure, night_count, guests |
What was asked for |
nights |
One entry per night: its date, what it costs, which band it fell in, whether it is a weekend |
nights_minor, extra_guest_minor, cleaning_minor |
The three parts |
total_minor |
What the stay comes to, which is the three parts added up |
rows |
The same, as the breakdown the receipt prints: code, label, amount_minor, discountable |
due_today_minor |
What the guest is asked for now — the deposit where there is one, the total where there is not |
deposit_minor, balance_due_at |
The deposit, and the epoch the rest falls due at (local midnight where the property is) |
due_in_full_today |
True when the property asks for a deposit but the guest is arriving too soon for one |
deposit_says |
One sentence about the two payments, or about why there is only one |
damage_deposit_minor |
A stated amount, never charged here |
cancellation |
The policy as days and percentages |
cancellation_says |
The same policy as sentences a guest reads |
timezone |
The zone every date above is in |
label |
"Sat 9 Aug to Sat 16 Aug, 7 nights" |
rows always sums to total_minor, and the cleaning row is the one marked discountable: false.
NightlyMode::offer() returns a Mode\Offer containing the interval and resource selections, plus:
price:unit_price_minorand the quote rows.deposit:amount_minor,balance_due_at,remind_atandsays, or null. The amount is a monetary value, not a percentage.label: the booking description.facts:unit(alwaysnight),nights,guests,arrival,departure,check_in,check_out, their spoken forms,timezone,damage_deposit_minor,due_today_minoranddeposit_says.allDay: always true.
Admin routes
Admin routes use /kahunacart/rentals for the rule editor and Rates tab. Guest operations use Bookings’ storefront routes, with rental data under mode.
Rentals reuses Bookings’ view and manage permissions. It declares no additional permissions.
See REST API → Rentals for route parameters and responses. Each route has an MCP tool prefixed kahunacart_rentals_. tests/Unit/McpManifestTest.php checks this correspondence.
Monetary fields use minor units and names ending in *_minor. Routes verify that a service exists and uses mode nightly; otherwise they return 404. Repository validation failures return 422 with an explanation.
An unavailable quote returns 200 with ok: false and a reason. This is a valid query result, not a validation error. A product without a nightly rule returns 404.
rates is ordered by ascending position; later bands take priority. base_minor is the base amount used by percentage bands.
For availability, holds and booking creation, use Bookings’ existing tools. Rentals tools manage rules and rates. See Run the store from an AI assistant.
Database tables
kahunacart_rental_rules
One rule row exists per nightly service, keyed by service_id. reset --orders retains this setup data.
| Column | Type | Means |
|---|---|---|
service_id |
BIGINT |
Bookings' service id. Unique per store |
check_in_time |
VARCHAR(5) |
Wall time in the property's zone, default 16:00 |
check_out_time |
VARCHAR(5) |
Wall time in the property's zone, default 10:00 |
min_nights |
INTEGER |
Default 1 |
max_nights |
INTEGER |
Default 28. Zero is no limit |
arrival_days |
VARCHAR(20) |
Comma-separated weekday numbers, 0 Sunday to 6 Saturday. Null is any day |
departure_days |
VARCHAR(20) |
The same |
guests_included |
INTEGER |
How many the nightly rate covers. Default 2 |
cleaning_fee_minor |
BIGINT |
Once per stay |
extra_guest_minor |
BIGINT |
Per guest per night over guests_included |
damage_deposit_minor |
BIGINT |
Stated only — the owner collects it |
avoid_gaps |
INTEGER |
The orphan-night rule. On by default |
deposit_percent |
INTEGER |
Null takes the whole amount at once |
balance_days_before |
INTEGER |
Days before arrival the rest is due |
cancellation_json |
TEXT |
[{days, refund_percent}, …], longest notice first |
pre_arrival_text |
TEXT |
Private instructions included only in the arrival email. |
Maximum guest capacity is stored in Bookings’ service party_size, not in the rental rules table.
pre_arrival_text is the one rule field that never leaves the admin. NightlyMode::load() takes it straight back out before Bookings ever sees it, so it is never in StayRules, never in effective, never in a quote, never in an offer's facts, never on a receipt and never in an earlier message. See The two messages.
kahunacart_rental_rates
Rate bands are setup data indexed by (service_id, starts_on, ends_on).
| Column | Type | Means |
|---|---|---|
service_id |
BIGINT |
Which property |
starts_on, ends_on |
CHAR(10) |
ISO dates, both ends inclusive, in the property's zone |
label |
VARCHAR(190) |
What a merchant calls it |
nightly_minor |
BIGINT |
A price a night — or percent, never both |
percent |
INTEGER |
Percentage of the base nightly price, from 1 to 1000. 130 means a 30% increase; 80 means a 20% reduction. |
weekend_minor |
BIGINT |
What a weekend night costs inside this band |
weekend_days |
VARCHAR(20) |
Which nights count as the weekend. Null is read as Friday and Saturday, never as "no weekend" |
min_nights |
INTEGER |
Minimum for stays arriving within the band. Bands cannot override maximum stays or departure weekdays. |
arrival_days |
VARCHAR(20) |
The same |
position |
INTEGER |
Higher wins where two bands cover one night |
These tables store configured prices and fees used to calculate quotes. The agreed payment amounts for a purchased stay remain on its order.
Scheduled jobs
| Type | What it does |
|---|---|
rentals.tick |
Finds upcoming and recently ended stays, then queues the relevant message jobs. Deduplicated for five minutes. |
rentals.send_pre_arrival |
The message that carries the owner's note. Claims the send before it makes it, so two workers cannot both send it |
rentals.send_thank_you |
Sends the post-stay thank-you email. |
Both messages are registered with core notifications and have independent switches. Cancelled, rejected, expired and no-show stays are excluded.
Event handlers
Rentals defines no events of its own. It handles eleven integration events and two autoloader registration events.
| Event | What it does |
|---|---|
onKahunaCartRegisterMigrations |
Appends this plugin's migrations/ directory |
onKahunaCartBookingsRegisterModes |
Registers nightly, with Mode\PaymentPolicy and the Mode\WholeDays marker |
onKahunaCartBookingsRegisterPickerFaces |
Hands Bookings assets/js/rentals.js, its stylesheet and this add-on's translated words, so the range picker reaches the page |
onKahunaCartBookingsRegisterAdminSlots |
The two screens this add-on draws inside the Bookings section |
onApiRegisterRoutes |
The nine admin routes above, behind Bookings' own permissions |
onKahunaCartRegisterNotifications |
The two messages a stay sends, as rows a merchant can switch off |
onKahunaCartRegisterJobHandlers |
The three job types above |
onKahunaCartWorkerTick |
Queues the tick |
onTwigTemplatePaths |
This plugin's templates/, so a theme can override either message by copying one file |
onShortcodeHandlers |
[kahunacart-availability] and its underscore spelling |
onTwigExtensions |
kahunacart_availability() |
onPluginsInitialized / onCliInitialize |
The autoloader, at priority 100001 |
Integration handlers check the plugin’s enabled setting. Autoloader registration always runs so existing class references remain resolvable.
Core pricing and deposit events
Rentals does not subscribe to these core events directly. Bookings handles them using the price and deposit returned by the mode. Use these events when debugging pricing or payment schedules:
| Event | What travels on it |
|---|---|
onKahunaCartCartLinePrice |
Supplies Quote::rows() and Quote::$totalMinor from the hold as included adjustments. The cleaning row is discountable: false. |
onKahunaCartOrderPaymentSchedule |
Supplies the deposit amount and balance deadline. Using an amount avoids percentage rounding across the whole order. |
onKahunaCartOrderPartPaid |
Confirms the booking after a deposit is received. |
onKahunaCartOrderBalanceOverdue |
Triggers the owner alert and prevents arrival-note delivery while payment remains overdue. |
See Events for the contracts themselves.
The shortcode and the Twig function
[kahunacart-availability product="beach-house" months="2" /]
[kahunacart_availability product="beach-house" /]
| Attribute | What it is |
|---|---|
product |
The product's slug. Required; anything else answers an empty string rather than an error |
service |
Accepted as a spelling of product, for anyone who thinks of it as the service |
months |
Maximum displayed months, from 1 to 12, default 2. Out-of-range values are clamped. Fewer months appear in narrow containers. |
{{ kahunacart_availability('beach-house') }}
{{ kahunacart_availability('beach-house', { months: 3 }) }}
The second argument overrides defaults from the first. A non-string product or rendering exception returns an empty string. Both shortcode spellings require Shortcode Core. The mount includes the script and stylesheet so assets remain available when processed page content is cached.
The calendar uses the picker’s availability endpoint. See Shortcodes and Twig functions.
Admin extension slots
Admin\SlotHooks mounts rental components into two existing Bookings slots. Rentals does not add a separate admin section.
| Slot | What goes in it |
|---|---|
bookings.service.fields |
admin-next/slots/service-fields.js — the stay rules, drawn inside the service editor the moment the way of booking is set to nightly |
bookings.tabs |
admin-next/slots/rates.js — the Rates tab: a year of the property's prices with the bands on it to scale |
Each mount specifies a script and the custom element it defines. Bookings serves it through an authenticated route and passes ctx, containing escaping helpers, pills, dialogs, stylesheets, currency and the API client. Service fields also receive the service, existing rule and onChange. A script-load failure displays an add-on error.
The rules panel passes its complete flat object through onChange. Bookings’ Save action calls NightlyMode::sanitize() and save() within the service transaction, storing both sets of rules together.
The scripts have no imports or build step. tests/js/slots-render-check.mjs mounts both components against a stub host using route response data. tests/Unit/SlotsRenderTest.php runs that check.
Template overrides
Override the four email files in templates/emails/ by copying matching filenames into user/themes/<your-theme>/templates/emails/:
rental-pre-arrival.html.twig, rental-pre-arrival.txt.twig, rental-thank-you.html.twig, rental-thank-you.txt.twig.
HTML emails inherit the core layout’s colours, logo and footer.
The command line
bin/plugin kahunacart-rentals nights <product> [--variant=] [--from=] [--to=] [--guests=1] [--quote=A..B] [--explain]
bin/plugin kahunacart-rentals seed [rental|bnb|all] [--timezone=]
product accepts an ID or slug. --from defaults to today in the property’s timezone; --to defaults to one month later. Supply --quote as 2026-08-09..2026-08-16. Add --explain to show each blocked night and its reason.
bin/plugin kahunacart-rentals nights beach-house --quote 2026-10-08..2026-10-14 --guests 6
The command prints rate groups, extras, total, payment due today and the balance deadline. nights uses the picker’s engine; CommandsTest checks that the CLI does not calculate prices independently.
seed defaults to the rental recipe and lists valid choices for an unknown name. Rentals uses its own Seed\RecipeRegistry because the Bookings registry is a final class with a fixed const array.
| Recipe | What it writes |
|---|---|
rental |
A beach house: sleeps eight, $249 a night, a summer rate and three stays in the book. See For holiday lets |
bnb |
Four identical doubles as one record, two nights minimum, breakfast as an option. See For B&Bs |
all |
Both |
Seed\Recipes\RentalRecipe extends Bookings’ Recipe for shared schedule, location, resource and service setup. Recipes are idempotent and use the store timezone. Seasonal bands use the current year unless that season has passed, then use the next year. Arrival instructions are left empty.
Model limitations
Written up rather than faked:
- Recurring weekend-only minimums. Minimums apply to a property or dated rate band. Supporting recurring weekday-specific minimums would require an additional field and a
quoteFor()check. - Imported calendars block every unit on the property record. Separately listed units need separate records and feeds.
- Mixed-timezone pools use the first bookable’s timezone, determined before the property is selected.
Tests
../grav-plugin-kahunacart/vendor/bin/phpunit -c phpunit.xml
tests/Support/RentalsFixture.php builds the core, Bookings and Rentals schemas on one in-memory database, using BookingsFixture. MigrationTest and EngineMatrixTest always run on SQLite. Set the same environment variables as core’s matrix tests to include MariaDB and PostgreSQL. The matrix verifies matching results across engines, including CHAR(10) date comparisons with >=.
Related
- REST API - how every route here is authenticated, and the conventions they all follow
- Events - the four core events a stay's price and deposit travel through
- Bookings for developers - booking modes, reservation checks and shared tables
- Run the store from an AI assistant - the MCP manifest the nine tools live in
- Shortcodes and Twig functions