Bookings is a complex extension which extends WooCommerce product types to add it’s own ‘Booking’ product type. Due to this, it’s a good example to CRUDify and implement data stores, both new concepts in 2.7.

Upon reviewing the current class, it’s obvious there is some room for refactoring due to the class for example rendering HTML. Ideally this should be avoided to keep the focus of the class data-only. The ideal structure is:

  • Bookings class – extends WC_Product and handles booking product data getters and setters.
  • Bookings data store – extends the core data stores to handle the storing of the booking class data to the database.
  • Functions/Display classes to handle HTML output.

Additionally, the extension needs to continue to be compatible with current 2.6.x versions of WooCommerce.

In testing, the extension actually works fine under 2.7, albeit with some notices such as:

Declaration of WC_Product_Booking::get_price() should be compatible with WC_Product::get_price($context = 'view')

These could have simply been patched on a case by case basis, but for Bookings we’ve chosen to fully CRUDify it which I’ll demonstrate in this post.

Making a home for deprecated methods

First since I intend to move HTML output functions out of the class, I need somewhere to place my deprecated methods for sake of tidyness. To do this, I created a WC_Legacy_Product_Booking class which extends WC_Product, and then made the original WC_Product_Booking class extend the legacy class so those methods are still available.

/**
 * Class for the booking product type.
 */
class WC_Product_Booking extends WC_Legacy_Product_Booking {

Defining the data store

Data stores handle the communication with the database and the actual CRUD actions. First I defined my data-store class:

/**
 * WC Bookable Product Data Store: Stored in CPT.
 */
class WC_Product_Booking_Data_Store_CPT extends WC_Product_Data_Store_CPT implements WC_Object_Data_Store_Interface {

}

Then I registered it via the WooCommerce filter woocommerce_data_stores in the main class constructor:

add_filter( 'woocommerce_data_stores', array( $this, 'register_data_stores' ) );

And the method:

    /**
     * Register data stores for bookings.
     *
     * @param  array  $data_stores
     * @return array
     */
    public function register_data_stores( $data_stores = array() ) {
        $data_stores['product-booking'] = 'WC_Product_Booking_Data_Store_CPT';
        return $data_stores;
    }

Getters and setters

Next I went through all the methods and collected the names of all of the booking specific meta keys where data is stored, and defined them in the class without their _wc_booking_ prefix. For each I added a basic getter and setter method.

For example, one item of meta data was named . I defined this in an array and added a getter and setter like this:

    /**
     * Stores product data.
     *
     * @var array
     */
    protected $extra_data = array(
        'qty' => 1,
    );

    /**
     * Merges booking product data into the parent object.
     *
     * @param int|WC_Product|object $product Product to init.
     */
    public function __construct( $product = 0 ) {
        $this->data = array_merge( $this->data, $this->extra_data );
        parent::__construct( $product );
    }

    /**
     * Get internal type.
     *
     * @return string
     */
    public function get_type() {
        return 'booking';
    }

    /**
     * Get the qty available to book per block.
     *
     * @param  string $context
     * @return integer
     */
    public function get_qty( $context = 'view' ) {
        return $this->get_prop( 'qty', $context );
    }

    /**
     * Set qty.
     * @param integer $value
     */
    public function set_qty( $value ) {
        $this->set_prop( 'qty', absint( $value ) );
    }

In this example, qty prop defaults to 1, and we use get_prop/set_prop to get and set the values (this is part of WooCommerce’s data classes and handles formatting, filtering, and changes). I repeated this for all props.

Reading props via the data store class

After declaring the props, getters, and setters, we need to implement a way to get the data into the props when the product is loaded. We do this via the data store.

First I defined a list of meta keys and how they translate to the new props (getters and setters):

    /**
     * Meta keys and how they transfer to CRUD props.
     *
     * @var array
     */
    private $booking_meta_key_to_props = array(
        '_wc_booking_qty' => 'qty',
        ...

In the above example, the meta key is _wc_booking_qty and the props are set_qty and get_qty so I defined qty.

Next I extended one of the methods the core product class defines to load meta data; read_product_data. In this, I call the parent read (it’s a product and thus should inherit everything the parent product loads), and then load all booking specific meta data.

    /**
     * Read product data. Can be overridden by child classes to load other props.
     *
     * @param WC_Product
     */
    protected function read_product_data( &$product ) {
        parent::read_product_data( $product );

        $set_props  = array();

        foreach ( $this->booking_meta_key_to_props as $key => $prop ) {
            $set_props[ $prop ] = get_post_meta( $product->get_id(), $key, true );
        }

        $product->set_props( $set_props );
    }

Saving props to the database

The data-store class also handles saving the props to the database on demand. The custom-post-type version of our data stores calls a helper method named update_post_meta which saves all the meta data to the database after the post has been created. We can override this method to run our own custom meta data saving.

    /**
     * Helper method that updates all the post meta for a product based on it's settings in the WC_Product class.
     *
     * @param WC_Product
     * @param bool $force Force all props to be written even if not changed. This is used during creation.
     * @since 2.7.0
     */
    protected function update_post_meta( &$product, $force = false ) {
        parent::update_post_meta( $product, $force );

        foreach ( $this->booking_meta_key_to_props as $key => $prop ) {
            update_post_meta( $product->get_id(), $key, $product->{ "get_$prop" }() );
        }
    }

This is looping over our props and updating the meta key values with the prop values.

Replacing the WP Admin save logic

WP Admin saves booking data using a series of update_post_meta calls in the meta box. With CRUD we can swap this out to instead set the props, and then call save on our CRUD object.

This is how it was handled with meta directly:

        $meta_to_save = array(
            '_wc_booking_base_cost'                  => 'float',
            '_wc_booking_cost'                       => 'float',
            '_wc_display_cost'                       => '',
            '_wc_booking_min_duration'               => 'int',
            // ...
        );

        foreach ( $meta_to_save as $meta_key => $sanitize ) {
            $value = ! empty( $_POST[ $meta_key ] ) ? $_POST[ $meta_key ] : '';
            switch ( $sanitize ) {
                case 'int' :
                    $value = $value ? absint( $value ) : '';
                    break;
                case 'float' :
                    $value = $value ? floatval( $value ) : '';
                    break;
                case 'yesno' :
                    $value = 'yes' === $value ? 'yes' : 'no';
                    break;
                case 'issetyesno' :
                    $value = $value ? 'yes' : 'no';
                    break;
                case 'max_date' :
                    $value = absint( $value );
                    if ( 0 == $value ) {
                        $value = 1;
                    }
                    break;
                default :
                    $value = sanitize_text_field( $value );
            }
            update_post_meta( $post_id, $meta_key, $value );
        }

Not only can we switch to prop setters here, we can also lessen the amount of formatting of values needed since this is handled by the setters themselves. We still need to prepare some values in a way the setters can understand, but this can be handled on a case by case basis.

The above function, compatible with WC 2.6.x, was hooked into the process product meta action:

add_action( 'woocommerce_process_product_meta', array( $this, 'save_product_data' ), 20 );

WC 2.7 (beta 2) has a new action which fires after props are set, but before save, which passes the product object. This is a perfect place to set additional props and have them all save at the same time.

/**
 * @since 2.7.0 to set props before save.
 */
do_action( 'woocommerce_admin_process_product_object', $product );

Hooking into this action is done like this:

add_action( 'woocommerce_admin_process_product_object', array( $this, 'set_props' ), 20 );

And then the callback checks the product type before running save logic:

    /**
     * Set data in 2.7.x+
     *
     * @param WC_Product $product
     */
    public function set_props( $product ) {
        // Only set props if the product is a bookable product.
        if ( ! is_a( $product, 'WC_Product_Booking' ) ) {
            return;
        }
        
        // ... save code here
    }
Actions pass variables by reference, so we can actually set props directly on `$product`.

For the save code, we just need to set props using the setter methods added earlier.

$product->set_props( array(
    'apply_adjacent_buffer'      => isset( $_POST['_wc_booking_apply_adjacent_buffer'] ),
    'availability'               => $this->get_posted_availability(),
    'base_cost'                  => wc_clean( $_POST['_wc_booking_base_cost'] ),
    // ...
) );

For backwards compatibility, we can call this on the old meta action shown earlier and reuse the above method, calling save manually.

    /**
     * Save Booking data for the product in 2.6.x.
     *
     * @param int $post_id
     */
    public function save_product_data( $post_id ) {
        if ( version_compare( WC_VERSION, '2.7', '>=' ) || 'booking' !== sanitize_title( stripslashes( $_POST['product-type'] ) ) ) {
            return;
        }

        $product = new WC_Product_Booking( $post_id );
        $this->set_props( $product );
        $product->save();
    }

Replacing get_post_meta calls in form fields

The post meta boxes have fields which allow users to edit product settings. These typically look something like:

woocommerce_wp_checkbox( array(
    'id'          => '_wc_booking_requires_confirmation',
    'label'       => __( 'Requires confirmation?', 'woocommerce-bookings' ),
    'description' => __( 'Check this box if the booking requires admin approval/confirmation. Payment will not be taken during checkout.', 'woocommerce-bookings' )
) );

In this example, WooCommerce would load meta called _wc_booking_requires_confirmation. In other cases, value can be manually defined and is usually a get_post_meta call.

To be compatible with CRUD, we actually want to load the values from the product objects directly using the getters.

woocommerce_wp_checkbox( array(
    'id'          => '_wc_booking_requires_confirmation',
    'value'       => $bookable_product->get_requires_confirmation( 'edit' ) ? 'yes' : 'no',
    'label'       => __( 'Requires confirmation?', 'woocommerce-bookings' ),
    'description' => __( 'Check this box if the booking requires admin approval/confirmation. Payment will not be taken during checkout.', 'woocommerce-bookings' )
) );

In the new example, $bookable_product is something I loaded like this:

$bookable_product = new WC_Product_Booking( $post->ID );

This ensures it’s of WC_Product_Booking type so I have access to bookings methods.

As for the method call to get_requires_confirmation, I pass edit context to the method so that the value is unfiltered.

Optimising code with CRUD

Working through the code, I noticed lots of places which could benefit with CRUD usage. One good example was the duplicate product logic:

// Duplicate persons
$persons = get_posts( array(
    'post_parent'    => $post->ID,
    'post_type'      => 'bookable_person',
    'post_status'    => 'publish',
    'posts_per_page' => -1,
    'orderby'        => 'menu_order',
    'order'          => 'asc',
) );

if ( $persons ) {
    $duplicator = include( WC()->plugin_path() . '/includes/admin/class-wc-admin-duplicate-product.php' );
    foreach ( $persons as $person ) {
        $duplicator->duplicate_product( $person, $new_post_id );
    }
}

I love the fact that we can use the CRUD system to make this a breeze:

// Clone and re-save person types.
foreach ( $product->get_person_types() as $person_type ) {
    $dupe_person_type = clone $person_type;
    $dupe_person_type->set_id( 0 );
    $dupe_person_type->set_parent_id( $new_post_id );
    $dupe_person_type->save();
}

Fixing other deprecation notices

Working through my debug logs I fixed deprecation calls with conditional logic, such as this:

if ( function_exists( 'wc_get_price_excluding_tax' ) ) {
    $display_price = wc_get_price_excluding_tax( $product, array( 'price' => $cost ) );
} else {
    $display_price = $product->get_price_excluding_tax( 1, $cost )
}

which fixed the notices:

WC_Product::get_price_excluding_tax is <strong>deprecated</strong> since version 2.7! Use wc_get_price_excluding_tax instead. in /srv/www/wordpress-default/wp-includes/functions.php on line 3828

Fixing AJAX select2 inputs

2.7 updated Select2 to version 4 meaning some ajax inputs which use hidden input boxes will no longer work. Example:

<input data-selected="<?php echo esc_attr( $user_string ); ?>" value="<?php echo esc_attr( $booking->get_customer_id() ); ?>" type="hidden" class="wc-customer-search" id="_booking_customer_id" name="_booking_customer_id" data-placeholder="<?php echo esc_attr( __( 'Guest', 'woocommerce-bookings' ) . ( $customer->name ? ' (' . $customer->name . ')' : '' ) ); ?>" data-allow_clear="true" />

These need to be switched to a real select box for v4 compatibility, so I did this with a conditional like so:

<?php if ( version_compare( WC_VERSION, '2.7', '<' ) ) : ?>
    <input type="hidden" name="_booking_customer_id" id="_booking_customer_id" class="wc-customer-search" value="<?php echo esc_attr( $booking->get_customer_id() ); ?>" data-selected="<?php echo esc_attr( $customer_string ); ?>" data-placeholder="<?php echo esc_attr( $guest_placeholder ); ?>" data-allow_clear="true" />
<?php else : ?>
    <select name="_booking_customer_id" id="_booking_customer_id" class="wc-customer-search" data-placeholder="<?php echo esc_attr( $guest_placeholder ); ?>" data-allow_clear="true">
        <?php if ( $booking->get_customer_id() ) : ?>
<option selected="selected" value="<?php echo esc_attr( $booking->get_customer_id() ); ?>"><?php echo esc_attr( $customer_string ); ?></option>
        <?php endif; ?> 
    </select>
<?php endif; ?>

Making data stores and objects compatible with 2.6.x

This turned out to be the most challenging aspect of the development process so I will warn you in advance supporting CRUD in 2.6.x and 2.7.x is possible but complex. If the plugin is not mission critical you could consider setting a minimum requirement of 2.7.x when live, but we couldn’t do this with bookings. So here is what I had to do:

  1. I removed all implements from the data-store and CRUD classes since the interfaces are not necessarily required and are not present in 2.6.x.
  2. Instead of extending WC_Data, I duplicated the 2.7.x version of WC_Data, called it WC_Bookings_Data. and extended that instead.
  3. I have 2 versions of WC_Bookings_Data loaded conditionally – 1 with all the methods, and 1 which just extends WC_Data for 2.7.x since that code is not required there.
  4. I copied across data store dependencies into bookings and only load them if using a version lower than 2.7.x.
    1. WC_Data_Exception
    2. WC_Data_Store_WP
    3. WC_Data_Store
    4. WC_Product_Data_Store_CPT
  5. Since 2.6.x does not have full CRUD objects for products, I decided to have my CRUD implementation ONLY handle meta data when running 2.6.x for bookable products.
  6. I added a proxy class for the bookable product type to load either a legacy class (which contained all data store methods) or the regular WC_Product class. This code looked like this:
if ( version_compare( WC_VERSION, '2.7', '<' ) ) {
    include_once( WC_BOOKINGS_ABSPATH . 'includes/compatibility/class-legacy-wc-product-booking.php' );
    class WC_Product_Booking_Compatibility extends Legacy_WC_Product_Booking {}
} else {
    class WC_Product_Booking_Compatibility extends WC_Product {}
}

/**
 * Class for the booking product type.
 */
class WC_Product_Booking extends WC_Product_Booking_Compatibility {
    //...

I had to do this since PHP does not support multiple inheritance and traits are 5.4+.

Wrapping up

I applied the same structure to bookable resources, bookings themselves, and person types; implementing data stores where it made sense.

Then it was a case of:

  • ensuring any meta calls or get_posts function calls were swapped for CRUD methods and new methods in the data store to keep that logic together in one place.
  • fixing all other deprecated function and arg calls (checking the debug log for these).
  • triple checking 2.6.x backwards compatibility.

The next version of bookings will be 2.7.x ready and fully backwards compatible 🙂

We’ll be updating our WIKI with more CRUD and data store examples in the coming days.

For now, I hope this post was helpful!