From 1b9c04962597964d261227a5e0e8db90912a7b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:26:47 +0000 Subject: [PATCH 1/3] Refactor booking list data to use typed model instead of raw array round-trip getBookingListiCal() previously re-fetched the Booking model by ID from the plain assoc array produced by getBookingListData(), even though that model was already available when the array was built. Introduce CommonsBooking\Model\BookingListEntry to pair each row's rendered array data with its originating Booking model, so internal consumers can use the typed model directly. The plain array form (still required by the commonsbooking_booking_filter hook and consumers) is now only produced at the webservice boundary, in getTemplateData()'s AJAX JSON response. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013tGSVYGXS2qFtR3rnUC9tz --- src/Model/BookingListEntry.php | 32 +++++++++++++++++++++++++ src/View/Booking.php | 43 ++++++++++++++++++++++++++-------- 2 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 src/Model/BookingListEntry.php diff --git a/src/Model/BookingListEntry.php b/src/Model/BookingListEntry.php new file mode 100644 index 000000000..e52d1d055 --- /dev/null +++ b/src/Model/BookingListEntry.php @@ -0,0 +1,32 @@ +booking = $booking; + $this->rowData = $rowData; + } + + /** + * Returns the row data as exposed at the webservice boundary (AJAX JSON response). + * + * @return array + */ + public function toArray(): array { + return $this->rowData; + } +} diff --git a/src/View/Booking.php b/src/View/Booking.php index 0c3ded1dc..30b245443 100755 --- a/src/View/Booking.php +++ b/src/View/Booking.php @@ -7,6 +7,7 @@ use Exception; use CommonsBooking\Helper\Wordpress; +use CommonsBooking\Model\BookingListEntry; use CommonsBooking\Plugin; use CommonsBooking\Settings\Settings; use CommonsBooking\Service\iCalendar; @@ -28,11 +29,30 @@ class Booking extends View { */ public static function getTemplateData(): void { header( 'Content-Type: application/json' ); - echo wp_json_encode( self::getBookingListData() ); + + // This is the webservice boundary: it's the only place where the typed BookingListEntry + // objects returned by getBookingListData() get turned into plain assoc arrays for JSON output. + $bookingListData = self::getBookingListData(); + if ( $bookingListData && array_key_exists( 'data', $bookingListData ) ) { + $bookingListData['data'] = array_map( + fn( BookingListEntry $entry ) => $entry->toArray(), + $bookingListData['data'] + ); + } + + echo wp_json_encode( $bookingListData ); wp_die(); // All ajax handlers die when finished } /** + * Returns paginated/filtered/sorted booking list data. + * + * NOTE: The 'data' entry of the returned array holds a list of {@see BookingListEntry} objects, + * pairing each row's typed Booking model with its rendered row data. Only the webservice + * boundary ({@see self::getTemplateData()}, the AJAX JSON response) should reduce these entries + * to their plain assoc array form via {@see BookingListEntry::toArray()}. Internal consumers + * (e.g. {@see self::getBookingListiCal()}) should use the Booking model directly instead. + * * @param int $postsPerPage * @param \WP_User|null $user * @@ -172,9 +192,11 @@ public static function getBookingListData( int $postsPerPage = 6, ?\WP_User $use $location = $booking->getLocation(); $locationTitle = $location ? $booking->getLocation()->post_title : commonsbooking_sanitizeHTML( __( 'Not available', 'commonsbooking' ) ); - // Prepare row data - // FIXME This untyped structure is exposed via the filter commonsbooking_booking_filter below, but the set of keys of the assoc array must not be changed. This is not ideal and should be either replace by a dedicated object type or removed entirely. - // If not, why not expose this as own type? + // Prepare row data. + // NOTE: This untyped structure is exposed via the filter commonsbooking_booking_filter + // below, so its set of keys is public API and must not change. It gets paired with the + // typed $booking model in a BookingListEntry right after, so this array itself should + // stay confined to the webservice boundary (see getTemplateData()) and not leak further. $rowData = [ 'postID' => $booking->ID, 'startDate' => $booking->getStartDate(), @@ -263,7 +285,7 @@ public static function getBookingListData( int $postsPerPage = 6, ?\WP_User $use } else { continue; } - $bookingDataArray['data'][] = $filteredRowData; + $bookingDataArray['data'][] = new BookingListEntry( $booking, $filteredRowData ); } } @@ -287,11 +309,11 @@ public static function getBookingListData( int $postsPerPage = 6, ?\WP_User $use // Init function to pass sort and order param to sorting callback $sorter = function ( $sort, $order ) { - return function ( $a, $b ) use ( $sort, $order ) { + return function ( BookingListEntry $a, BookingListEntry $b ) use ( $sort, $order ) { if ( $order == 'asc' ) { - return strcasecmp( $a[ $sort ], $b[ $sort ] ); + return strcasecmp( $a->rowData[ $sort ], $b->rowData[ $sort ] ); } else { - return strcasecmp( $b[ $sort ], $a[ $sort ] ); + return strcasecmp( $b->rowData[ $sort ], $a->rowData[ $sort ] ); } }; }; @@ -527,8 +549,9 @@ public static function getBookingListiCal( $user = null ) { $calendar = new iCalendar(); - foreach ( $bookingList['data'] as $bookingData ) { - $booking = \CommonsBooking\Repository\Booking::getPostById( $bookingData['postID'] ); + /** @var BookingListEntry $entry */ + foreach ( $bookingList['data'] as $entry ) { + $booking = $entry->booking; if ( ! $booking->isConfirmed() ) { continue; } From 6d93d372876a79e63fe94ed18d30026cbab09c9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:34:40 +0000 Subject: [PATCH 2/3] Make BookingListEntry array-compatible and defer serialization to the webservice boundary Row data now stays a typed BookingListEntry object all the way through, including across the commonsbooking_booking_filter hook, instead of being unwrapped back to a plain array beforehand. BookingListEntry implements ArrayAccess/IteratorAggregate/ Countable so existing filter callbacks written against the old plain array ($rowData['postID'], foreach, count, returning null to drop a row) keep working unchanged; callbacks returning a plain array are still accepted and re-wrapped. getTemplateData() no longer manually converts entries to arrays before JSON encoding - BookingListEntry implements JsonSerializable, so wp_json_encode() reduces each entry to its row array only at that point, which is the actual webservice boundary. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013tGSVYGXS2qFtR3rnUC9tz --- src/Model/BookingListEntry.php | 68 +++++++++++++++++++++++++---- src/View/Booking.php | 80 +++++++++++++++++----------------- 2 files changed, 101 insertions(+), 47 deletions(-) diff --git a/src/Model/BookingListEntry.php b/src/Model/BookingListEntry.php index e52d1d055..28b10e623 100644 --- a/src/Model/BookingListEntry.php +++ b/src/Model/BookingListEntry.php @@ -2,19 +2,32 @@ namespace CommonsBooking\Model; +use ArrayAccess; +use ArrayIterator; +use Countable; +use IteratorAggregate; +use JsonSerializable; +use Traversable; + /** - * Pairs a Booking model with the row data rendered for it in the booking list view - * (@see \CommonsBooking\View\Booking::getBookingListData()). + * One row of the booking list (@see \CommonsBooking\View\Booking::getBookingListData()), pairing + * the typed Booking model with its rendered row data. + * + * Implements ArrayAccess/IteratorAggregate/Countable so that existing commonsbooking_booking_filter + * callbacks written against the old plain assoc array (e.g. $rowData['postID'], foreach/count over + * it) keep working unchanged against this object. Callbacks that pass the row into a function that + * requires a literal array (array_diff_key, preg_grep, etc.) need to call toArray() first, since + * PHP's array_* functions don't accept ArrayAccess objects. * - * Keeps a typed reference to the originating Booking so that internal consumers (e.g. iCalendar - * generation) can use the model directly instead of re-fetching it by ID from the row data array, - * which is only meant to be exposed as-is at the webservice boundary (the AJAX JSON response). + * This object is only reduced to a plain array at the webservice boundary, via JsonSerializable, + * when wp_json_encode() serializes the AJAX response in Booking::getTemplateData(). Everywhere else + * it stays typed. */ -class BookingListEntry { +class BookingListEntry implements ArrayAccess, IteratorAggregate, Countable, JsonSerializable { public Booking $booking; - public array $rowData; + private array $rowData; public function __construct( Booking $booking, array $rowData ) { $this->booking = $booking; @@ -22,11 +35,50 @@ public function __construct( Booking $booking, array $rowData ) { } /** - * Returns the row data as exposed at the webservice boundary (AJAX JSON response). + * Returns the row data as a plain array. Use this when passing the row into code that requires + * a literal array (e.g. array_diff_key(), preg_grep()). * * @return array */ public function toArray(): array { return $this->rowData; } + + public function offsetExists( $offset ): bool { + return array_key_exists( $offset, $this->rowData ); + } + + public function offsetGet( $offset ): mixed { + return $this->rowData[ $offset ] ?? null; + } + + public function offsetSet( $offset, $value ): void { + if ( $offset === null ) { + $this->rowData[] = $value; + } else { + $this->rowData[ $offset ] = $value; + } + } + + public function offsetUnset( $offset ): void { + unset( $this->rowData[ $offset ] ); + } + + public function getIterator(): Traversable { + return new ArrayIterator( $this->rowData ); + } + + public function count(): int { + return count( $this->rowData ); + } + + /** + * Called by wp_json_encode()/json_encode() at the webservice boundary. Not meant to be called + * directly elsewhere -- use toArray() for that. + * + * @return array + */ + public function jsonSerialize(): array { + return $this->rowData; + } } diff --git a/src/View/Booking.php b/src/View/Booking.php index 30b245443..223d59f6d 100755 --- a/src/View/Booking.php +++ b/src/View/Booking.php @@ -30,17 +30,10 @@ class Booking extends View { public static function getTemplateData(): void { header( 'Content-Type: application/json' ); - // This is the webservice boundary: it's the only place where the typed BookingListEntry - // objects returned by getBookingListData() get turned into plain assoc arrays for JSON output. - $bookingListData = self::getBookingListData(); - if ( $bookingListData && array_key_exists( 'data', $bookingListData ) ) { - $bookingListData['data'] = array_map( - fn( BookingListEntry $entry ) => $entry->toArray(), - $bookingListData['data'] - ); - } - - echo wp_json_encode( $bookingListData ); + // This is the webservice boundary: wp_json_encode() reduces the BookingListEntry objects + // returned by getBookingListData() to plain arrays right here, via BookingListEntry::jsonSerialize(). + // Nowhere earlier in the codebase are they turned into arrays. + echo wp_json_encode( self::getBookingListData() ); wp_die(); // All ajax handlers die when finished } @@ -48,10 +41,13 @@ public static function getTemplateData(): void { * Returns paginated/filtered/sorted booking list data. * * NOTE: The 'data' entry of the returned array holds a list of {@see BookingListEntry} objects, - * pairing each row's typed Booking model with its rendered row data. Only the webservice - * boundary ({@see self::getTemplateData()}, the AJAX JSON response) should reduce these entries - * to their plain assoc array form via {@see BookingListEntry::toArray()}. Internal consumers - * (e.g. {@see self::getBookingListiCal()}) should use the Booking model directly instead. + * pairing each row's typed Booking model with its rendered row data. These stay typed objects + * throughout -- they behave like the old plain assoc array (ArrayAccess/Countable/foreach) for + * compatibility with existing commonsbooking_booking_filter callbacks, but are only actually + * turned into arrays where required: at the webservice boundary when JSON-encoded + * ({@see self::getTemplateData()}), or explicitly via {@see BookingListEntry::toArray()}. + * Internal consumers (e.g. {@see self::getBookingListiCal()}) should use the Booking model + * directly via the entry's public $booking property instead of re-fetching it. * * @param int $postsPerPage * @param \WP_User|null $user @@ -192,11 +188,9 @@ public static function getBookingListData( int $postsPerPage = 6, ?\WP_User $use $location = $booking->getLocation(); $locationTitle = $location ? $booking->getLocation()->post_title : commonsbooking_sanitizeHTML( __( 'Not available', 'commonsbooking' ) ); - // Prepare row data. - // NOTE: This untyped structure is exposed via the filter commonsbooking_booking_filter - // below, so its set of keys is public API and must not change. It gets paired with the - // typed $booking model in a BookingListEntry right after, so this array itself should - // stay confined to the webservice boundary (see getTemplateData()) and not leak further. + // Prepare row data. Its set of keys is public API (exposed via commonsbooking_booking_filter + // and the AJAX JSON response below), so it must not change. It gets paired with the typed + // $booking model into a BookingListEntry before being passed to the filter. $rowData = [ 'postID' => $booking->ID, 'startDate' => $booking->getStartDate(), @@ -261,31 +255,39 @@ public static function getBookingListData( int $postsPerPage = 6, ?\WP_User $use $rowData['actions'] = $actions; /** - * Default assoc array of row data and the booking object, which gets added to the booking list data result. + * Row data for one booking, which gets added to the booking list data result. * - * NOTE: Upon using this filter hook, the schema of associative array keys needs to be adhered to in order to not break the booking list. - * See $rowData in this function, for the valid keys. + * NOTE: $row behaves like the plain assoc array this filter used to receive + * (ArrayAccess/Countable/foreach all still work, e.g. $row['postID']), so the + * schema of keys still needs to be adhered to in order to not break the booking + * list. See $rowData in this function, for the valid keys. A plain array can + * still be returned instead for backwards compatibility. Return null to drop the + * row from the list. * - * @param array|mixed|null $rowData assoc array of one row booking data + * @param BookingListEntry|array|null $row row data for one booking * @param \CommonsBooking\Model\Booking $booking booking model of one row booking data * *@since 2.7.3 */ - $filteredRowData = apply_filters( 'commonsbooking_booking_filter', $rowData, $booking ); - - // Only includes non-null array row data objects - if ( isset( $filteredRowData ) && is_array( $filteredRowData ) ) { - if ( WP_DEBUG ) { - // Logs absent keys, relative to the original row data keys, could cause problems - $absentKeys = array_diff_key( $filteredRowData, $rowData ); - if ( count( $absentKeys ) > 0 ) { - error_log( 'After commonsbooking_booking_filter: Filtered rows have missing keys: ' . join( ',', array_keys( $absentKeys ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log - } - } - } else { + $filteredRow = apply_filters( 'commonsbooking_booking_filter', new BookingListEntry( $booking, $rowData ), $booking ); + + // Only includes non-null row data, normalized back to a BookingListEntry for + // callbacks that still return a plain array. + if ( is_array( $filteredRow ) ) { + $filteredRow = new BookingListEntry( $booking, $filteredRow ); + } elseif ( ! $filteredRow instanceof BookingListEntry ) { continue; } - $bookingDataArray['data'][] = new BookingListEntry( $booking, $filteredRowData ); + + if ( WP_DEBUG ) { + // Logs absent keys, relative to the original row data keys, could cause problems + $absentKeys = array_diff_key( $filteredRow->toArray(), $rowData ); + if ( count( $absentKeys ) > 0 ) { + error_log( 'After commonsbooking_booking_filter: Filtered rows have missing keys: ' . join( ',', array_keys( $absentKeys ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + } + + $bookingDataArray['data'][] = $filteredRow; } } @@ -311,9 +313,9 @@ public static function getBookingListData( int $postsPerPage = 6, ?\WP_User $use $sorter = function ( $sort, $order ) { return function ( BookingListEntry $a, BookingListEntry $b ) use ( $sort, $order ) { if ( $order == 'asc' ) { - return strcasecmp( $a->rowData[ $sort ], $b->rowData[ $sort ] ); + return strcasecmp( $a[ $sort ], $b[ $sort ] ); } else { - return strcasecmp( $b->rowData[ $sort ], $a->rowData[ $sort ] ); + return strcasecmp( $b[ $sort ], $a[ $sort ] ); } }; }; From 45865d08705157be6f951a9c7a27dde93f9beb4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:45:49 +0000 Subject: [PATCH 3/3] Give BookingListEntry typed properties for the known row-data fields Turn the previously untyped $rowData array inside BookingListEntry into real typed properties (postID: int, item: string, content: array, ...) for the fixed set of fields the booking list view always produces. Fields outside that schema -- which commonsbooking_booking_filter callbacks are free to add -- still land in an untyped overflow array, since the hook's contract allows arbitrary extension and a fixed set of properties can't accept that. toArray()/jsonSerialize() reassemble the known typed fields plus the overflow bag into a plain array, keeping 'bookingCode' entirely absent (not present as null) when a booking has no code, to match the original array's exact shape and avoid false positives in the WP_DEBUG absent-key diff log. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013tGSVYGXS2qFtR3rnUC9tz --- src/Model/BookingListEntry.php | 147 +++++++++++++++++++++++++++++---- src/View/Booking.php | 17 ++-- 2 files changed, 140 insertions(+), 24 deletions(-) diff --git a/src/Model/BookingListEntry.php b/src/Model/BookingListEntry.php index 28b10e623..1900c64d3 100644 --- a/src/Model/BookingListEntry.php +++ b/src/Model/BookingListEntry.php @@ -13,11 +13,16 @@ * One row of the booking list (@see \CommonsBooking\View\Booking::getBookingListData()), pairing * the typed Booking model with its rendered row data. * - * Implements ArrayAccess/IteratorAggregate/Countable so that existing commonsbooking_booking_filter - * callbacks written against the old plain assoc array (e.g. $rowData['postID'], foreach/count over - * it) keep working unchanged against this object. Callbacks that pass the row into a function that - * requires a literal array (array_diff_key, preg_grep, etc.) need to call toArray() first, since - * PHP's array_* functions don't accept ArrayAccess objects. + * The known/fixed row fields (see KNOWN_FIELD_DEFAULTS) are real typed properties, so internal code + * can use e.g. $entry->postID directly. commonsbooking_booking_filter callbacks are free to add + * arbitrary extra keys though (that's part of the hook's contract), so anything outside the known + * schema is kept in an untyped overflow bag instead of being rejected. + * + * Implements ArrayAccess/IteratorAggregate/Countable so that existing filter callbacks written + * against the old plain assoc array (e.g. $rowData['postID'], foreach/count over it) keep working + * unchanged against this object. Callbacks that pass the row into a function that requires a literal + * array (array_diff_key, preg_grep, etc.) need to call toArray() first, since PHP's array_* + * functions don't accept ArrayAccess objects. * * This object is only reduced to a plain array at the webservice boundary, via JsonSerializable, * when wp_json_encode() serializes the AJAX response in Booking::getTemplateData(). Everywhere else @@ -27,49 +32,157 @@ class BookingListEntry implements ArrayAccess, IteratorAggregate, Countable, Jso public Booking $booking; - private array $rowData; + public int $postID; + + public int $startDate; + + /** + * @var int|false Mirrors \CommonsBooking\Model\Booking::getEndDate(), which can return false. + */ + public $endDate; + + public string $startDateFormatted; + + public string $endDateFormatted; + + public string $item; + + public string $location; + + public string $locationAddr; + + public mixed $locationLat; + + public mixed $locationLong; + + public string $bookingDate; + + public string $user; + + public string $status; + + public mixed $fullDay; + + public string $calendarLink; + + public array $content; + + public ?array $bookingCode; + + public string $actions; + + /** + * Overflow bag for keys outside the known schema above -- e.g. custom fields added by a + * commonsbooking_booking_filter callback. Kept untyped on purpose, since the hook explicitly + * allows arbitrary extension. + * + * @var array + */ + private array $extra = []; + + /** + * Default values for the known/fixed row fields, also used as the authoritative list of which + * keys are known (as opposed to landing in the untyped overflow bag). + */ + private const KNOWN_FIELD_DEFAULTS = [ + 'postID' => 0, + 'startDate' => 0, + 'endDate' => 0, + 'startDateFormatted' => '', + 'endDateFormatted' => '', + 'item' => '', + 'location' => '', + 'locationAddr' => '', + 'locationLat' => 0, + 'locationLong' => 0, + 'bookingDate' => '', + 'user' => '', + 'status' => '', + 'fullDay' => null, + 'calendarLink' => '', + 'content' => [], + 'bookingCode' => null, + 'actions' => '', + ]; public function __construct( Booking $booking, array $rowData ) { $this->booking = $booking; - $this->rowData = $rowData; + foreach ( self::KNOWN_FIELD_DEFAULTS as $field => $default ) { + $this->$field = $default; + } + foreach ( $rowData as $key => $value ) { + $this->offsetSet( $key, $value ); + } } /** * Returns the row data as a plain array. Use this when passing the row into code that requires * a literal array (e.g. array_diff_key(), preg_grep()). * + * NOTE: 'bookingCode' is omitted entirely (not included as null) when a booking has no code, + * matching the original conditional array_key_exists()/isset() behavior for that key. + * * @return array */ public function toArray(): array { - return $this->rowData; + $data = []; + foreach ( array_keys( self::KNOWN_FIELD_DEFAULTS ) as $field ) { + if ( $field === 'bookingCode' && $this->bookingCode === null ) { + continue; + } + $data[ $field ] = $this->$field; + } + + return array_merge( $data, $this->extra ); } public function offsetExists( $offset ): bool { - return array_key_exists( $offset, $this->rowData ); + if ( array_key_exists( $offset, self::KNOWN_FIELD_DEFAULTS ) ) { + return $this->$offset !== null; + } + + return array_key_exists( $offset, $this->extra ); } public function offsetGet( $offset ): mixed { - return $this->rowData[ $offset ] ?? null; + if ( array_key_exists( $offset, self::KNOWN_FIELD_DEFAULTS ) ) { + return $this->$offset; + } + + return $this->extra[ $offset ] ?? null; } public function offsetSet( $offset, $value ): void { if ( $offset === null ) { - $this->rowData[] = $value; - } else { - $this->rowData[ $offset ] = $value; + $this->extra[] = $value; + return; } + + if ( array_key_exists( $offset, self::KNOWN_FIELD_DEFAULTS ) ) { + $this->$offset = $value; + return; + } + + $this->extra[ $offset ] = $value; } public function offsetUnset( $offset ): void { - unset( $this->rowData[ $offset ] ); + if ( array_key_exists( $offset, self::KNOWN_FIELD_DEFAULTS ) ) { + // Known fields are real typed properties and structurally always present; "unsetting" + // one resets it to its schema default rather than truly removing it. + $this->$offset = self::KNOWN_FIELD_DEFAULTS[ $offset ]; + return; + } + + unset( $this->extra[ $offset ] ); } public function getIterator(): Traversable { - return new ArrayIterator( $this->rowData ); + return new ArrayIterator( $this->toArray() ); } public function count(): int { - return count( $this->rowData ); + return count( $this->toArray() ); } /** @@ -79,6 +192,6 @@ public function count(): int { * @return array */ public function jsonSerialize(): array { - return $this->rowData; + return $this->toArray(); } } diff --git a/src/View/Booking.php b/src/View/Booking.php index 223d59f6d..c0c260547 100755 --- a/src/View/Booking.php +++ b/src/View/Booking.php @@ -41,13 +41,16 @@ public static function getTemplateData(): void { * Returns paginated/filtered/sorted booking list data. * * NOTE: The 'data' entry of the returned array holds a list of {@see BookingListEntry} objects, - * pairing each row's typed Booking model with its rendered row data. These stay typed objects - * throughout -- they behave like the old plain assoc array (ArrayAccess/Countable/foreach) for - * compatibility with existing commonsbooking_booking_filter callbacks, but are only actually - * turned into arrays where required: at the webservice boundary when JSON-encoded - * ({@see self::getTemplateData()}), or explicitly via {@see BookingListEntry::toArray()}. - * Internal consumers (e.g. {@see self::getBookingListiCal()}) should use the Booking model - * directly via the entry's public $booking property instead of re-fetching it. + * pairing each row's typed Booking model with its rendered row data. Known row fields (postID, + * startDate, item, ...) are real typed properties on the entry (e.g. $entry->postID); fields + * added by a commonsbooking_booking_filter callback outside that schema land in an untyped + * overflow bag instead of being rejected. Entries stay typed objects throughout -- they behave + * like the old plain assoc array (ArrayAccess/Countable/foreach) for compatibility with existing + * filter callbacks, but are only actually turned into arrays where required: at the webservice + * boundary when JSON-encoded ({@see self::getTemplateData()}), or explicitly via + * {@see BookingListEntry::toArray()}. Internal consumers (e.g. {@see self::getBookingListiCal()}) + * should use the Booking model directly via the entry's public $booking property instead of + * re-fetching it. * * @param int $postsPerPage * @param \WP_User|null $user