# booktics/trunk/core/cart/cart-session.php

Booktics – Appointment Booking Calendar for Service Businesses, version trunk. 357 lines.

- Page: https://pluginprobe.com/plugins/booktics/trunk/code/core/cart/cart-session.php
- Raw: https://pluginprobe.com/plugins/booktics/trunk/raw/core/cart/cart-session.php
- Modified: 2026-10-11T10:01:32+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booktics/trunk/code/core/cart/cart-session.php#L10-L20`.

```php
<?php

namespace Booktics\Cart;

/**
 * Class Cart_Session
 * Handles cart session management for the Booktics plugin
 *
 * Every session this class opens is closed again before the method returns so
 * the PHP session file lock is never held for the rest of the request. A held
 * lock blocks other requests from the same browser, such as WordPress Site
 * Health loopback requests to the REST API. Sessions started by other code are
 * left open, because closing them would discard that code's later writes.
 *
 * @package Booktics\Cart
 */
class Cart_Session {

    /**
     * Session key used to store cart data in a PHP session
     *
     * @var string
     */
    const SESSION_KEY = 'booktics_cart';

    /**
     * Start a PHP session when one is not running yet.
     *
     * Sessions are started lazily instead of on every request so that a session
     * is never opened after output has already been flushed, which triggers a
     * "Session cannot be started after headers have already been sent" warning.
     *
     * @return bool True when $_SESSION is usable.
     */
    private static function maybe_start_session(): bool {
        if ( PHP_SESSION_ACTIVE === session_status() ) {
            return true;
        }

        if ( PHP_SESSION_DISABLED === session_status() ) {
            return false;
        }

        if ( headers_sent() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
            return false;
        }

        return session_start(
            array(
                'cookie_httponly' => true,
                'cookie_secure'   => is_ssl(),
                'cookie_samesite' => 'Lax',
                'use_strict_mode' => true,
            )
        );
    }

    /**
     * Open the session, run a callback against it and release it again.
     *
     * The session is closed only when this call opened it, so a session owned by
     * another plugin stays open for its owner.
     *
     * @param callable $callback Receives nothing and returns the callback result.
     *
     * @return array{0: bool, 1: mixed} Whether the session was usable, and the callback result.
     */
    private static function with_session( callable $callback ): array {
        $should_close = PHP_SESSION_ACTIVE !== session_status();

        if ( ! self::maybe_start_session() ) {
            return array( false, null );
        }

        try {
            return array( true, $callback() );
        } finally {
            if ( $should_close ) {
                session_write_close();
            }
        }
    }

    /**
     * Read the stored cart from the open session.
     *
     * Must only be called while the session is open.
     *
     * @return array{items: array, total: float, coupon: string}
     */
    private static function read_session_cart(): array {
        return self::to_cart( self::read_raw_session_cart() );
    }

    /**
     * Read the unsanitized cart from the open session.
     *
     * Must only be called while the session is open.
     *
     * @return mixed Raw session value, or null when nothing is stored.
     */
    private static function read_raw_session_cart() {
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via sanitize_session_data() in to_cart(). Session data is never slashed, so it is not unslashed.
        return ! empty( $_SESSION[ self::SESSION_KEY ] ) ? $_SESSION[ self::SESSION_KEY ] : null;
    }

    /**
     * Turn raw session data into a sanitized cart.
     *
     * @param mixed $raw_data Raw session value.
     *
     * @return array{items: array, total: float, coupon: string}
     */
    private static function to_cart( $raw_data ): array {
        $session_data = self::sanitize_session_data( $raw_data );

        return is_array( $session_data ) ? $session_data : self::empty_cart();
    }

    /**
     * Read, modify and write the cart while holding the session lock once.
     *
     * Holding the lock across the whole read-modify-write stops parallel
     * requests from the same browser from overwriting each other's changes.
     *
     * @param callable $callback Receives the current cart and returns the new cart.
     *
     * @return array|null The stored cart, or null when the session is unavailable.
     */
    private static function mutate( callable $callback ): ?array {
        list( , $cart ) = self::with_session(
            function () use ( $callback ) {
                $cart                          = $callback( self::read_session_cart() );
                $_SESSION[ self::SESSION_KEY ] = $cart;

                return $cart;
            }
        );

        return $cart;
    }

    /**
     * Get the amount a cart item adds to the cart total.
     *
     * Session data always carries a subtotal, which is 0 when none was stored,
     * so a zero subtotal falls back to the item price.
     *
     * @param array $item Cart item.
     *
     * @return float
     */
    private static function item_amount( array $item ): float {
        $subtotal = floatval( $item['subtotal'] ?? 0 );

        return $subtotal > 0 ? $subtotal : floatval( $item['price'] ?? 0 );
    }

    /**
     * Sum the amounts of the given cart items.
     *
     * @param array $items Cart items.
     *
     * @return float
     */
    private static function calculate_total( array $items ): float {
        return array_reduce(
            $items,
            function ( $total, $item ) {
                return $total + self::item_amount( $item );
            },
            0.00
        );
    }

    /**
     * Get an empty cart structure
     *
     * @return array{items: array, total: float, coupon: string}
     */
    private static function empty_cart(): array {
        return array(
            'items'  => array(),
            'total'  => 0.00,
            'coupon' => '',
        );
    }

    /**
     * Get cart data from session
     *
     * @return array{items: array, total: float}
     */
    public static function get() {
        // Sanitize after the lock is released, since sanitization filters may be slow.
        list( $available, $raw_data ) = self::with_session(
            function () {
                return self::read_raw_session_cart();
            }
        );

        return $available ? self::to_cart( $raw_data ) : self::empty_cart();
    }

    /**
     * Sanitize raw cart session data
     *
     * @param mixed $data Raw data retrieved from the session.
     *
     * @return mixed Sanitized array or null if input is not an array.
     */
    private static function sanitize_session_data( $data ) {
        if ( ! is_array( $data ) ) {
            return null;
        }

        $sanitized_items = array();

        if ( ! empty( $data['items'] ) && is_array( $data['items'] ) ) {
            foreach ( $data['items'] as $item ) {
                if ( ! is_array( $item ) ) {
                    continue;
                }

                $sanitized_item = array(
                    'uuid'           => sanitize_text_field( $item['uuid'] ?? '' ),
                    'service_id'     => absint( $item['service_id'] ?? 0 ),
                    'team_member_id' => absint( $item['team_member_id'] ?? 0 ),
                    'date'           => sanitize_text_field( $item['date'] ?? '' ),
                    'start_time'     => sanitize_text_field( $item['start_time'] ?? '' ),
                    'end_time'       => sanitize_text_field( $item['end_time'] ?? '' ),
                    'price'          => floatval( $item['price'] ?? 0 ),
                    'subtotal'       => floatval( $item['subtotal'] ?? 0 ),
                    'total'          => floatval( $item['total'] ?? 0 ),
                );

                $sanitized_item = apply_filters( 'booktics_pro_sanitize_cart_item', $sanitized_item, $item );

                $sanitized_items[] = $sanitized_item;
            }
        }

        return array(
            'items'  => $sanitized_items,
            'total'  => floatval( $data['total'] ?? 0.00 ),
            'coupon' => sanitize_text_field( $data['coupon'] ?? '' ),
        );
    }

    /**
     * Set cart data in session
     *
     * @param array $cart Cart data to store in session
     *
     * @return bool True when the cart was stored.
     */
    public static function set( array $cart ): bool {
        list( $available ) = self::with_session(
            function () use ( $cart ) {
                $_SESSION[ self::SESSION_KEY ] = $cart;
            }
        );

        return $available;
    }

    /**
     * Clear cart data from session
     *
     * @return bool True when the cart was cleared.
     */
    public static function clear(): bool {
        list( $available ) = self::with_session(
            function () {
                unset( $_SESSION[ self::SESSION_KEY ] );
            }
        );

        return $available;
    }

    /**
     * Add an item to the cart session
     *
     * @param array $item
     *
     * @return array
     */
    public static function add_item( array $item ): array {
        $cart = self::mutate(
            function ( $cart ) use ( $item ) {
                $cart['items'][] = $item;
                $cart['total']   = floatval( $cart['total'] ?? 0 ) + self::item_amount( $item );

                return apply_filters( 'booktics_pro_cart_session', $cart );
            }
        );

        if ( null === $cart || empty( $cart['items'] ) ) {
            return $item;
        }

        return end( $cart['items'] );
    }

    /**
     * Delete an item from the cart session
     *
     * @param string $id
     *
     * @return void
     */
    public static function delete_item( string $id ) {
        self::mutate(
            function ( $cart ) use ( $id ) {
                $cart['items'] = array_values(
                    array_filter(
                        $cart['items'],
                        function ( $item ) use ( $id ) {
                            return $item['uuid'] !== $id;
                        }
                    )
                );
                $cart['total'] = self::calculate_total( $cart['items'] );

                return $cart;
            }
        );
    }

    /**
     * Edit an item in the cart session
     *
     * @param string $uuid UUID of the item to edit
     * @param array $new_data New data to update the item with
     *
     * @return array The updated cart, or an empty cart when the session is unavailable.
     */
    public static function edit_item( string $uuid, array $new_data ): array {
        $cart = self::mutate(
            function ( $cart ) use ( $uuid, $new_data ) {
                $cart['items'] = array_map(
                    function ( $item ) use ( $uuid, $new_data ) {
                        return $item['uuid'] === $uuid ? array_merge( $item, $new_data ) : $item;
                    },
                    $cart['items']
                );
                $cart['total'] = self::calculate_total( $cart['items'] );

                return apply_filters( 'booktics_pro_cart_session', $cart );
            }
        );

        return $cart ?? self::empty_cart();
    }
}

```
