Skip to main content

Cross-object availability sync

Block every other Booking Object in a category as soon as one object in that category is booked. Use this when several booking objects share a single physical resource (one guide, one driver, one room key) that can only serve one session at a time.

Use case

A guided buggy tour operator offers 4 routes with different durations, each set up as a separate Booking Object so prices, schedule and route description can be configured independently:

RouteDuration
Galega60 min
Cabana90 min
Bentos120 min
Baloico180 min

The catch: there is only one guide. While the guide is leading a Cabana tour at 10:00-11:30, no other route can start in a window that would overlap with that booking.

The snippet listens to order events and writes virtual "fully booked" guest counts onto the other tours' availability table, so the calendar widget renders those windows as unavailable. When the order is canceled or deleted, the block is rolled back from saved meta.

Schema

A 90-minute Cabana Tour is booked at 10:00. The snippet shifts that window for every other tour in the category, based on each neighbour's own duration:

09:00 10:00 11:00 12:00
| | | |
Cabana 90m ──────────────[█████████████████]───────── ← booked
Galega 60m ──────────[████████████████████]───────── blocked 09:01-11:29
Bentos 120m ────[█████████████████████████]────────── blocked 08:01-11:29
Baloico 180m [█████████████████████████████]────────── blocked 07:01-11:29

For each neighbour the snippet shifts the start back by the neighbour's own duration (-1 second on each edge keeps strictly back-to-back slots open). The effect: any start time on a neighbour that would still be running during the booked window is marked unavailable.

Code

Cross-object availability sync
<?php
const BABE_BUGGY_CATEGORY_SLUG = 'buggy-tours';
const BABE_BUGGY_META_KEY = '_buggy_blocked_slots';
const BABE_BUGGY_LOG_TAG = '[BUGGY_LOCK]';

function babe_buggy_log(string $msg, $data = null): void {
$line = BABE_BUGGY_LOG_TAG . ' ' . $msg;
if ($data !== null) {
$line .= ' | ' . wp_json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
error_log($line);
}

function babe_buggy_normalize_dt(string $s): string {
try {
return (new DateTime($s))->format('Y-m-d H:i:s');
} catch (Exception $e) {
return $s;
}
}

function babe_buggy_add_minutes(string $s, int $minutes): string {
try {
$dt = new DateTime($s);
$dt->modify('+' . $minutes . ' minutes');
return $dt->format('Y-m-d H:i:s');
} catch (Exception $e) {
return $s;
}
}

function babe_buggy_shifted_range(string $from, string $to, int $neighbour_duration_min): array {
$shifted_from = new DateTime($from);
$shifted_from->modify("-{$neighbour_duration_min} minutes");
$shifted_from->modify('+1 second');

$shifted_to = new DateTime($to);
$shifted_to->modify('-1 second');

return [
'from' => $shifted_from->format('Y-m-d H:i:s'),
'to' => $shifted_to->format('Y-m-d H:i:s'),
];
}

function babe_buggy_get_duration_minutes(int $obj_id): ?int {
$duration = get_post_meta($obj_id, 'duration_' . BABE_BUGGY_CATEGORY_SLUG, true);
if (!is_array($duration)) return null;
$days = (int) ($duration['d'] ?? 0);
$hours = (int) ($duration['h'] ?? 0);
$minutes = (int) ($duration['i'] ?? 0);
$total = $days * 1440 + $hours * 60 + $minutes;
return $total > 0 ? $total : null;
}

function babe_buggy_get_max_guests(int $obj_id): int {
$max = (int) get_post_meta($obj_id, 'guests', true);
return $max > 0 ? $max : 4;
}

function babe_buggy_is_buggy_tour(int $booking_obj_id): bool {
if (!class_exists('BABE_Post_types')) return false;
$taxonomy = BABE_Post_types::$categories_tax;
$term_slugs = wp_get_object_terms($booking_obj_id, $taxonomy, ['fields' => 'slugs']);
return in_array(BABE_BUGGY_CATEGORY_SLUG, (array) $term_slugs, true);
}

function babe_buggy_is_active_status(?string $status): bool {
$inactive = ['draft', 'not_available', 'canceled'];
return $status && !in_array($status, $inactive, true);
}

function babe_buggy_get_other_tour_ids(int $exclude_id): array {
if (!class_exists('BABE_Post_types')) return [];
return get_posts([
'post_type' => BABE_Post_types::$booking_obj_post_type,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'exclude' => [$exclude_id],
'tax_query' => [[
'taxonomy' => BABE_Post_types::$categories_tax,
'field' => 'slug',
'terms' => BABE_BUGGY_CATEGORY_SLUG,
]],
]);
}

function babe_buggy_extract_order_items(int $order_id): array {
if (!class_exists('BABE_Order')) return [];

$items = BABE_Order::get_order_items($order_id);
$output = [];
foreach ((array) $items as $row) {
$obj_id = isset($row['booking_obj_id']) ? (int) $row['booking_obj_id'] : 0;
$start_raw = $row['meta']['date_from'] ?? '';
if (!$obj_id || !$start_raw) continue;

$duration_min = babe_buggy_get_duration_minutes($obj_id);
if ($duration_min === null) continue;

$start = babe_buggy_normalize_dt($start_raw);
$end = babe_buggy_add_minutes($start, $duration_min);

$output[] = [
'booking_obj_id' => $obj_id,
'date_from' => $start,
'date_to' => $end,
'duration_min' => $duration_min,
];
}
return $output;
}

function babe_buggy_refresh_after_changes(array $obj_ids): void {
foreach ($obj_ids as $obj_id) {
BABE_Calendar_functions::refresh_av_guests($obj_id);
babe_buggy_log("refresh_av_guests for obj_id=$obj_id");
}
BABE_Calendar_functions::reset_cache();
}

function babe_buggy_block_for_order(int $order_id): void {
babe_buggy_log('block_for_order() start', ['order_id' => $order_id]);

if (get_post_meta($order_id, BABE_BUGGY_META_KEY, true)) {
babe_buggy_log('already blocked, skipping');
return;
}

$items = babe_buggy_extract_order_items($order_id);
babe_buggy_log('extracted items', ['count' => count($items), 'items' => $items]);
if (!$items) {
babe_buggy_log('NO ITEMS - aborting');
return;
}

$blocked_records = [];
$touched_ids = [];

foreach ($items as $idx => $item) {
if (!babe_buggy_is_buggy_tour($item['booking_obj_id'])) {
babe_buggy_log("item[$idx] not a buggy tour, skipping");
continue;
}

$other_ids = babe_buggy_get_other_tour_ids($item['booking_obj_id']);
babe_buggy_log("item[$idx] other tour ids", $other_ids);
if (!$other_ids) continue;

foreach ($other_ids as $other_id) {
$neighbour_duration = babe_buggy_get_duration_minutes($other_id);
if ($neighbour_duration === null) {
babe_buggy_log("other_id=$other_id has no duration, skipping");
continue;
}

$range = babe_buggy_shifted_range($item['date_from'], $item['date_to'], $neighbour_duration);
$neighbour_max = babe_buggy_get_max_guests($other_id);

babe_buggy_log("blocking other_id=$other_id (dur=$neighbour_duration min, max=$neighbour_max)", $range);

BABE_Calendar_functions::update_av_guests(
$other_id, $range['from'], $range['to'], $neighbour_max
);

$blocked_records[] = [
'other_id' => $other_id,
'from' => $range['from'],
'to' => $range['to'],
'max' => $neighbour_max,
];
$touched_ids[$other_id] = true;
}
}

if ($blocked_records) {
babe_buggy_refresh_after_changes(array_keys($touched_ids));
update_post_meta($order_id, BABE_BUGGY_META_KEY, $blocked_records);
}
babe_buggy_log('block_for_order() done', ['total_blocked' => count($blocked_records)]);
}

function babe_buggy_unblock_for_order(int $order_id): void {
babe_buggy_log('unblock_for_order() start', ['order_id' => $order_id]);
$blocked_records = get_post_meta($order_id, BABE_BUGGY_META_KEY, true);
if (!$blocked_records || !is_array($blocked_records)) {
babe_buggy_log('no blocked meta - nothing to unblock');
return;
}

$touched_ids = [];
foreach ($blocked_records as $record) {
BABE_Calendar_functions::update_av_guests(
(int) $record['other_id'], $record['from'], $record['to'], -((int) $record['max'])
);
$touched_ids[(int) $record['other_id']] = true;
babe_buggy_log("unblocked other_id={$record['other_id']}", $record);
}

babe_buggy_refresh_after_changes(array_keys($touched_ids));
delete_post_meta($order_id, BABE_BUGGY_META_KEY);
babe_buggy_log('unblock_for_order() done');
}

function babe_buggy_on_order_created(int $order_id, array $post_arr): void {
babe_buggy_log('--- HOOK babe_order_created ---', ['order_id' => $order_id]);
babe_buggy_block_for_order($order_id);
}

function babe_buggy_on_order_canceled(int $order_id): void {
babe_buggy_log('--- HOOK babe_order_canceled ---', ['order_id' => $order_id]);
babe_buggy_unblock_for_order($order_id);
}

function babe_buggy_on_status_updated(int $order_id, string $new_status, string $old_status): void {
babe_buggy_log('--- HOOK babe_order_status_updated ---', [
'order_id' => $order_id,
'old_status' => $old_status,
'new_status' => $new_status,
]);
$was_active = babe_buggy_is_active_status($old_status);
$is_active = babe_buggy_is_active_status($new_status);

if ($was_active && !$is_active) {
babe_buggy_unblock_for_order($order_id);
} elseif (!$was_active && $is_active) {
babe_buggy_block_for_order($order_id);
}
}

function babe_buggy_on_order_deleted(int $post_id): void {
if (!class_exists('BABE_Post_types')) return;
if (get_post_type($post_id) !== BABE_Post_types::$order_post_type) return;
babe_buggy_log('--- HOOK before_delete_post (order) ---', ['order_id' => $post_id]);
babe_buggy_unblock_for_order($post_id);
}

function babe_buggy_register_hooks(): void {
add_action('babe_order_created', 'babe_buggy_on_order_created', 20, 2);
add_action('babe_order_canceled', 'babe_buggy_on_order_canceled', 20, 1);
add_action('babe_order_status_updated', 'babe_buggy_on_status_updated', 20, 3);
add_action('before_delete_post', 'babe_buggy_on_order_deleted', 10, 1);
}

babe_buggy_register_hooks();

Configuration constants

ConstantPurpose
BABE_BUGGY_CATEGORY_SLUGBooking Category slug whose objects mutually block each other. Change to match your taxonomy term.
BABE_BUGGY_META_KEYOrder post meta key where blocked ranges are stored - used for exact rollback on cancel/delete.
BABE_BUGGY_LOG_TAGPrefix written to error_log so you can grep the log.

Key helpers

  • babe_buggy_shifted_range - produces the conflict window for each neighbour. Moves the booking start back by the neighbour's own duration; -1 second on each edge keeps strictly adjacent slots free.
  • babe_buggy_get_duration_minutes - reads duration_<category_slug> post meta and flattens days/hours/minutes into a single integer.
  • babe_buggy_extract_order_items - pulls items from the order via BABE_Order::get_order_items, computes each item's end time and returns the list the blocker iterates over.
  • babe_buggy_block_for_order / babe_buggy_unblock_for_order - write/revert virtual "fully booked" guest counts on neighbour objects via BABE_Calendar_functions::update_av_guests, and persist the exact ranges in order meta so rollback is symmetric.

Hooks

HookWhen it firesWhat the snippet does
babe_order_createdA new order is created (after checkout).Block neighbours for every buggy item in the order.
babe_order_canceledOrder is canceled.Restore neighbour availability from saved meta.
babe_order_status_updatedStatus transition (e.g. paidcanceled or draftpaid).Block on transitions into active states, unblock on transitions out.
before_delete_postAn order post is permanently deleted.Run a final unblock so neighbours are never left frozen.
Active vs inactive statuses

The snippet treats draft, not_available and canceled as inactive. Any other status counts as active and reserves neighbour capacity.

Install

  1. Save the code into your child theme's functions.php. See the WordPress child themes codex for setup.
  2. Make sure your Booking Category slug matches BABE_BUGGY_CATEGORY_SLUG (admin → BA Book Everything → Booking Categories).
  3. Create one Booking Object per route, all attached to that category, with the right Duration and Maximum number of Guests.
  4. Make a test booking on one route - every other route should show the conflicting time window as unavailable in the booking form widget.