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:
[code language=”php”]Declaration of WC_Product_Booking::get_price() should be compatible with WC_Product::get_price($context = ‘view’)[/code]
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.
[code language=”php”]/**
* Class for the booking product type.
*/
class WC_Product_Booking extends WC_Legacy_Product_Booking {[/code]
Defining the data store
Data stores handle the communication with the database and the actual CRUD actions. First I defined my data-store class:
[code language=”php”]/**
* 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 {
}[/code]
Then I registered it via the WooCommerce filter woocommerce_data_stores
in the main class constructor:
[code language=”php”]add_filter( ‘woocommerce_data_stores’, array( $this, ‘register_data_stores’ ) );[/code]
And the method:
[code language=”php”] /**
* 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;
}[/code]
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:
[code language=”php”] /**
* 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 ) );
}[/code]
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):
[code language=”php”] /**
* Meta keys and how they transfer to CRUD props.
*
* @var array
*/
private $booking_meta_key_to_props = array(
‘_wc_booking_qty’ => ‘qty’,
…[/code]
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.
[code language=”php”] /**
* 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 );
}[/code]
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.
[code language=”php”] /**
* 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" }() );
}
}
[/code]
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:
[code language=”php”] $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 );
}[/code]
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:
[code language=”php”]add_action( ‘woocommerce_process_product_meta’, array( $this, ‘save_product_data’ ), 20 );[/code]
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.
[code language=”php”]/**
* @since 2.7.0 to set props before save.
*/
do_action( ‘woocommerce_admin_process_product_object’, $product );[/code]
Hooking into this action is done like this:
[code language=”php”]add_action( ‘woocommerce_admin_process_product_object’, array( $this, ‘set_props’ ), 20 );[/code]
And then the callback checks the product type before running save logic:
[code language=”php”] /**
* 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
}[/code]
For the save code, we just need to set props using the setter methods added earlier.
[code language=”php”]$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’] ),
// …
) );
[/code]
For backwards compatibility, we can call this on the old meta action shown earlier and reuse the above method, calling save manually.
[code language=”php”] /**
* 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();
}[/code]
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:
[code language=”php”]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’ )
) );[/code]
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.
[code language=”php”]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’ )
) );[/code]
In the new example, $bookable_product
is something I loaded like this:
[code language=”php”]$bookable_product = new WC_Product_Booking( $post->ID );[/code]
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:
[code language=”php”]// 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 );
}
}[/code]
I love the fact that we can use the CRUD system to make this a breeze:
[code language=”php”]// 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();
}[/code]
Fixing other deprecation notices
Working through my debug logs I fixed deprecation calls with conditional logic, such as this:
[code language=”php”]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 )
}[/code]
which fixed the notices:
[code language=”php”]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[/code]
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:
[code language=”php”]<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" />
[/code]
These need to be switched to a real select box for v4 compatibility, so I did this with a conditional like so:
[code language=”php”]<?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; ?>[/code]
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:
- 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. - Instead of extending
WC_Data
, I duplicated the 2.7.x version ofWC_Data
, called itWC_Bookings_Data
. and extended that instead. - I have 2 versions of
WC_Bookings_Data
loaded conditionally – 1 with all the methods, and 1 which just extendsWC_Data
for 2.7.x since that code is not required there. - I copied across data store dependencies into bookings and only load them if using a version lower than 2.7.x.
WC_Data_Exception
WC_Data_Store_WP
WC_Data_Store
WC_Product_Data_Store_CPT
- 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.
- 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:
[code language=”php”]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 {
//…[/code]
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!
Leave a Reply