Skip to main content
A Flow is the central concept in UTB Product Builder. It encapsulates all the logic for a single product type: rendering the landing form, validating cart data, preparing order metadata, and calculating dynamic pricing. Every WooCommerce product managed by the plugin is assigned to exactly one flow. Create a new flow when you need to:
  • Support a new type of purchasable product (e.g., events, workshops, diplomas).
  • Add a custom frontend form with its own field set and validation rules.
  • Apply product-type-specific pricing logic beyond a flat WooCommerce price.

Two ways to implement a flow

Implement FlowInterface

Full control over every detail. Implement all methods from scratch. Suitable when you need behaviour that AbstractFlow cannot provide.

Extend AbstractFlow

Recommended approach. AbstractFlow handles WooCommerce hook registration, cart validation wiring, dynamic pricing dispatch, order meta persistence, and sanitization helpers. You only implement the three abstract methods.

FlowInterface — complete method reference

All flows must satisfy UTB\ProductBuilder\Flows\FlowInterface. The interface is defined in includes/flows/FlowInterface.php.
interface FlowInterface {
    /** Unique machine-readable identifier for this flow. */
    public function get_id(): string;

    /** Human-readable name shown in the admin panel. */
    public function get_name(): string;

    /** Short description shown in the admin panel. */
    public function get_description(): string;

    /** Dashicon class for the admin UI (e.g. 'dashicons-awards'). */
    public function get_icon(): string;

    /** Register all WordPress hooks this flow needs. Called once on init. */
    public function init(): void;

    /**
     * Return true if this flow should handle the given product.
     * AbstractFlow implements this by querying FlowRegistry.
     */
    public function applies_to_product(int $product_id): bool;

    /**
     * Return shortcodes registered by this flow.
     * Format: [ 'shortcode_tag' => 'method_name' ]
     */
    public function get_shortcodes(): array;

    /**
     * Return AJAX actions registered by this flow.
     * Format: [ 'action_name' => 'method_name' ]
     * AbstractFlow registers both wp_ajax_ and wp_ajax_nopriv_ variants.
     */
    public function get_ajax_endpoints(): array;

    /**
     * Validate form data before the item is added to the cart.
     * Return true if valid, or a string error message if not.
     *
     * @param array $post_data Raw $_POST data.
     * @return bool|string
     */
    public function validate_cart_data(array $post_data);

    /**
     * Build the metadata array that will be stored on the cart item.
     * Keys prefixed with _utb_ are automatically persisted to the order.
     *
     * @param array $post_data Raw $_POST data.
     * @return array
     */
    public function prepare_cart_metadata(array $post_data): array;

    /**
     * Return the final price for a cart item, or null to keep the
     * WooCommerce product price unchanged.
     *
     * @param array $cart_item_data The full cart item array, including metadata.
     * @return float|null
     */
    public function calculate_dynamic_price(array $cart_item_data): ?float;
}

What AbstractFlow gives you for free

Extending UTB\ProductBuilder\Flows\AbstractFlow wires up the following automatically inside init():
  • Registers each entry returned by get_shortcodes() via add_shortcode().
  • Registers each entry returned by get_ajax_endpoints() for both authenticated (wp_ajax_) and unauthenticated (wp_ajax_nopriv_) requests.
  • Hooks into woocommerce_add_to_cart_validation to call your validate_cart_data().
  • Hooks into woocommerce_add_cart_item_data to call your prepare_cart_metadata().
  • Hooks into woocommerce_before_calculate_totals to call your calculate_dynamic_price().
  • Hooks into woocommerce_checkout_create_order_line_item to persist all _utb_* metadata keys to the order.
  • Hooks into wp_enqueue_scripts to call your enqueue_assets() (override as needed).
  • Strips standard WooCommerce product page elements and injects your form via render_custom_content() when the visitor lands on an applicable product page.
It also provides helper methods for sanitization, nonce handling, and AJAX responses. See AJAX endpoints for details.

Required methods when extending AbstractFlow

Three methods are abstract — you must implement them:

render_custom_content(): void

Outputs the HTML that replaces the standard WooCommerce product summary. AbstractFlow wraps this in a <div class="utb-product-flow-container"> element automatically.
protected function render_custom_content(): void
{
    // Echo your form HTML here.
    // $this->get_id() returns your flow's ID for scoping CSS.
    echo '<form id="my-flow-form" method="post" enctype="multipart/form-data">';
    // ... fields ...
    echo '</form>';
}

prepare_cart_metadata(array $post_data): array

Converts raw $_POST data into a key/value array attached to the cart item. Any key prefixed with _utb_ is automatically saved to the WooCommerce order line item.
public function prepare_cart_metadata(array $post_data): array
{
    return [
        '_utb_applicant_name' => $this->sanitize_text($post_data['applicant_name'] ?? ''),
        '_utb_selected_date'  => $this->sanitize_text($post_data['event_date'] ?? ''),
    ];
}

validate_cart_data(array $post_data)

Runs before the cart item is added. Return true to allow it, or a non-empty string to block it. The string is shown to the customer as a WooCommerce notice.
public function validate_cart_data(array $post_data)
{
    if (empty($post_data['applicant_name'])) {
        return 'Please fill in all required fields.';
    }
    return true;
}

Registering flows

Flows are registered with FlowRegistry::register(). The recommended place is the utb_pb_register_flows action hook, which fires after the plugin is fully loaded.
add_action('utb_pb_register_flows', function () {
    \UTB\ProductBuilder\Flows\FlowRegistry::register(new MyPlugin\MyCustomFlow());
});
You can register flows from your own plugin or theme. The utb_pb_register_flows hook fires before FlowRegistry::init_all() is called, so your flow’s init() method will run alongside the built-in flows.

Complete minimal example

The following is a fully working custom flow that registers a text field form, validates it on add-to-cart, stores the value in the order, and keeps the default WooCommerce product price.
<?php
namespace MyPlugin;

use UTB\ProductBuilder\Flows\AbstractFlow;

class EventRegistrationFlow extends AbstractFlow
{
    public function get_id(): string
    {
        return 'event_registration';
    }

    public function get_name(): string
    {
        return 'Event Registration';
    }

    public function get_description(): string
    {
        return 'Custom flow for event ticket sales with attendee name capture.';
    }

    public function get_icon(): string
    {
        return 'dashicons-tickets-alt';
    }

    public function get_shortcodes(): array
    {
        return [
            'event_registration_form' => 'render_form_shortcode',
        ];
    }

    public function get_ajax_endpoints(): array
    {
        return []; // No AJAX needed for this minimal flow.
    }

    /** Shortcode callback — outputs the form HTML. */
    public function render_form_shortcode(array $atts): string
    {
        ob_start();
        $this->render_custom_content();
        return ob_get_clean();
    }

    protected function render_custom_content(): void
    {
        $product_id = get_queried_object_id();
        ?>
        <form id="event-registration-form" method="post">
            <label for="attendee_name">Attendee name <span>*</span></label>
            <input type="text" id="attendee_name" name="attendee_name" required>
            <input type="hidden" name="add-to-cart" value="<?php echo esc_attr($product_id); ?>">
            <button type="submit">Register</button>
        </form>
        <?php
    }

    public function validate_cart_data(array $post_data)
    {
        if (empty(trim($post_data['attendee_name'] ?? ''))) {
            return 'Attendee name is required.';
        }
        return true;
    }

    public function prepare_cart_metadata(array $post_data): array
    {
        return [
            '_utb_attendee_name' => $this->sanitize_text($post_data['attendee_name']),
        ];
    }

    public function calculate_dynamic_price(array $cart_item_data): ?float
    {
        // Return null to keep the WooCommerce product price unchanged.
        return null;
    }
}
Then register it:
add_action('utb_pb_register_flows', function () {
    \UTB\ProductBuilder\Flows\FlowRegistry::register(new MyPlugin\EventRegistrationFlow());
});
Finally, assign the flow to a product from the UTB Builder admin panel, or programmatically:
// Assign flow 'event_registration' to product ID 42.
\UTB\ProductBuilder\Flows\FlowRegistry::assign_flow_to_product(42, 'event_registration');
Never hardcode product IDs in code that runs across environments. IDs differ between local, staging, and production. Use the admin panel assignment instead, or read IDs from a configurable source.

Build docs developers (and LLMs) love