,

Building FunnelWheel’s Feeds Module: A Technical Deep Dive

A look at FunnelWheel Feeds, its core functionality, and the architecture designed for future growth.

I’ve spent the last stretch of FunnelWheel development on the Feeds module — the bell-icon notification system that shows WooCommerce stores their own live data back to customers: recent orders, live viewer counts, restock alerts, product recommendations. This is both what it does and, in more detail, how it’s actually built underneath — interfaces, the commerce boundary, and a rule engine that turned out to be bigger than the module itself.

What Feeds is

A bell icon sits in the storefront corner. Click it, get a dropdown of notification items — “Sarah just bought the Classic Hoodie,” “3 people viewing this right now,” “Back in stock: Size M.” Click an item, get a detail panel — a simple link-through, or a full lead-capture form.

Ten item types ship today: order, order_social, review, live, coupon, restock, recommendation, announce, welcome, custom. Three are static — admin writes the content, it displays as-is. The other seven are dynamic, each backed by a resolver pulling live store data at render time.

The resolver contract

Every dynamic type implements one interface:

interface ResolverInterface {
    public function resolve( array $item, array $context ): array;
}

$item is the saved DB row — type, static fallback text, admin-configured dynamic_fields, display rules. $context is runtime page state (post_id, product_id, category_ids, is_cart, is_checkout). The return value is a sparse override array merged into $item before render — return [] and the static fallback just displays, so a resolver fails soft without any special-casing by the caller.

In practice you extend ResolverAbstract, which implements the interface and gives you the plumbing — field readers, post/user helpers, a time_ago() formatter — plus the one method every resolver’s return statement funnels through:

class MyPlugin_Announcement_Resolver extends ResolverAbstract {
    public function resolve( array $item, array $context ): array {
        $post_id = $this->get_field( $item, 'post_id', 0 );
        $post    = get_post( (int) $post_id );
        if ( ! $post ) return [];

        return $this->merge([
            'text' => '📢 ' . get_the_title( $post ),
            'meta' => $this->time_ago( get_post_time( 'U', false, $post ) ),
        ]);
    }
}

merge() is a hard whitelist — text, meta, image, images, dynamic_meta, placeholders, menu_items — with key-specific sanitization, not a blanket call. placeholders gets forced through sanitize_text_field() on every value regardless of what the resolver sent, because those values substitute into a modal body that’s already wp_kses_post()-sanitized at save time — a resolver returning markup there would quietly defeat that. menu_items is the interesting one: a resolver can override an entire menu payload’s admin-configured items at render time — this is how the recommendation type injects live products into what’s otherwise static config — but it runs through a private sanitize_menu_items() mirroring the admin save-path’s own sanitizer field-by-field, since resolver output never touches the save path and never gets that sanitization for free otherwise.

The type registry

One class is the source of truth for what types exist — get_all() (JSON-safe, for the admin UI), get() (server-only, includes the resolver), and resolve(). Third-party registration is a filter, not inheritance:

add_filter( 'fw_feeds_notification_types', function( array $types ): array {
    $types['flash_sale'] = [
        'id'       => 'flash_sale',
        'icon'     => '⚡',
        'label'    => 'Flash Sale',
        'fields'   => [[ 'key' => 'end_date', 'label' => 'Sale ends', 'type' => 'text' ]],
        'rules'    => [ 'global', 'product', 'category' ],
        'resolver' => new MyPlugin_FlashSale_Resolver(),
    ];
    return $types;
});

In WP_DEBUG mode, get() validates the resolver actually implements ResolverInterface before trusting it — a malformed third-party registration gets neutralized and logged rather than fataling mid-render. One array entry, one resolver class, zero changes to the render pipeline, admin UI, or REST layer.

The commerce boundary

Resolvers never call wc_get_product() or touch WC()->cart directly — they go through an interface with a dozen methods (get_product, get_popular_product_ids, get_recent_orders_product_ids, get_recently_viewed_product_ids, get_customer_recent_purchases, and so on), one concrete WooCommerceAdapter implementation, swappable via fw_feeds_commerce_adapter. Typed DTOs (ProductDTO, OrderDTO, CouponDTO, ReviewDTO) cross the boundary instead of raw WooCommerce objects. Access comes through a trait, not inheritance, specifically so resolver unit tests can inject a fake adapter and assert on strategy logic without WooCommerce loaded at all:

trait CommerceAwareTrait {
    public function __construct( ?CommerceAdapterInterface $adapter = null ) { $this->adapter = $adapter; }
    protected function adapter(): CommerceAdapterInterface {
        return $this->adapter ??= apply_filters( 'fw_feeds_commerce_adapter', new WooCommerceAdapter() );
    }
}

The interface’s contract: every method fails soft — empty array or null, never a thrown exception — because the recommendation resolver’s fallback chain depends on that to degrade gracefully instead of fataling the page.

The honest fallback behind “recommendations”

Worth being upfront about, because it’s the part most “AI-powered recommendation” widgets don’t explain: six recommendation strategies exist, and five of them fall back to best-sellers whenever their real data source is empty — not a bug, by design. frequently_bought_together and others_also_viewed need a base product or real product-page context. trending needs real order history a fresh store doesn’t have yet. personalized needs a logged-in customer with completed purchases. recently_viewed needs actual browsing history — and this one had its own infrastructure problem: WooCommerce’s native recently-viewed cookie is set server-side on template_redirect, a hook caching plugins routinely skip. The fix was a separate cookie written client-side in JS on page load, so it works regardless of whether the page was served from cache — read back through the same commerce adapter when the resolver runs.

The rule engine isn’t Feeds’ — it’s borrowed

Targeting logic — which items show on which pages, for which customers — doesn’t live in the Feeds module at all. It’s a standalone ~30-file library (src/Libs/RuleEngine) with its own RuleInterface, concrete rules in category folders (Cart/CartTotal.php, Cart/CartProduct.php, Customer/CustomerRole.php), and an evaluator supporting flat and arbitrarily nested AND/OR groups. Here’s the actual fallback chain inside CartTotal::is_match():

if (isset($environment['cart_subtotal'])) {
    $cart_subtotal = (float) $environment['cart_subtotal'];
} elseif (isset($environment['cart_items'])) {
    foreach ($environment['cart_items'] as $item) {
        $cart_subtotal += (float)($item['line_subtotal'] ?? 0) + (float)($item['line_subtotal_tax'] ?? 0);
    }
} elseif (function_exists('WC') && WC()->cart) {
    $cart_subtotal = (float) WC()->cart->get_subtotal();
}

Three tiers — explicit value, generic cart_items shape, raw WooCommerce fallback. The middle tier is the real multi-platform extension point: a non-WooCommerce integration hooks funnelwheel_environment_cart_items to supply a normalized array, and every Cart* rule picks it up automatically, because they were all written against the environment-array shape first and the WC fallback second — no per-rule changes needed.

Feeds’ own FeedsTypes::passes_rules() is just a caller into this — a legacy scope-string mode (global | product | category | cart | checkout | logged_in) for items saved before the engine existed, and a full rule-engine mode ($rules['engine'] + $rules['conditions']) for everything new, both reachable through the same method so old saves keep evaluating correctly without migration.

Rendering and payloads

What happens on click is a payload type, not a fixed template: modal_info, modal_form (routes to a lead-capture handler), menu (sub-list), products (zero-config — contents are never admin-saved, always generated fresh at render time so a recommendation widget never goes stale between loads), link, or none. Any item whose resolver returns multiple images automatically switches to a stacked row layout — title on its own line, thumbnail strip below — driven by what the resolver returns, not by the item’s configured type.

Beyond the bell icon, the whole widget is available as a dynamic Gutenberg block (server-rendered via render_callback, not statically saved) and a shortcode, both resolving through the same render path — the shortcode matters because classic PHP themes are still the real-world majority for WooCommerce stores, not block themes.

Why it’s built this way

None of this complexity is decorative. The practical payoff shows up in what comes next: Offers, the order-bump/cart-upsell module, needs product data, commerce operations, and cart-based targeting — and gets all three for free. CartTotal, CartProduct, CartCategory already exist, already handle the environment-vs-WooCommerce fallback correctly, already plug into the nested AND/OR evaluator. The commerce adapter and DTOs are already platform-decoupled. The module-specific work is a type registry and a couple of resolvers; the parts that are easy to get subtly wrong — sanitizing resolver output, evaluating rules against live cart state — aren’t rewritten per module, they’re shared infrastructure the whole plugin draws from.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *