Skip to Content
WooCommerceWordPress

How to Match Products and Categories in WordPress: Developer Guide

How to Match Products and Categories in WordPress: Developer Guide

Almost every WordPress plugin that does conditional logic needs to match products and categories in WordPress-style: “apply this discount to these specific products OR anything in these categories,” “show this widget only on posts tagged X,” “free shipping for cart items in these categories.” The pattern is universal — store selected IDs/slugs, then check whether the current product/post matches the stored list.

The pattern is simple in principle but trips up newer developers in three places: (1) how to safely store an array in WordPress options or post meta, (2) how to retrieve and decode it without errors, (3) how to combine product-by-ID matching with category-by-taxonomy matching using AND/OR logic. This guide covers all three, plus performance tuning and common mistakes.

Quick verdict: store arrays with wp_json_encode(); retrieve with json_decode( ..., true ) and cast to (array) for null safety; match products with in_array() + product ID; match categories with WordPress’s built-in has_term() function; combine with PHP’s native boolean operators. The full pattern is ~10 lines of code once you know the shape.

match products and categories WordPress: quick reference

match products and categories in WordPress — visual reference and overview

If you are evaluating match products and categories WordPress for your next project, you are weighing real trade-offs between cost, complexity, ownership, and time-to-launch. The right match products and categories WordPress decision depends on a handful of variables — team capacity, scope clarity, and how much ongoing maintenance you can absorb. The summary below is the 60-second version; the rest of this guide unpacks the nuance.

  • match products and categories WordPress pricing typically ranges based on scope clarity, integration count, and ongoing support requirements.
  • match products and categories WordPress timelines vary from days (small scope) to months (enterprise scope) depending on complexity.
  • The biggest variable in match products and categories WordPress is requirements clarity at the brief stage — vague briefs produce vague quotes.
  • Vendor selection for match products and categories WordPress matters more than tool selection — the right team beats the right stack.
  • match products and categories WordPress ROI is positive when scope is bounded, deliverables are specified, and success criteria are measurable.

For complementary perspectives on match products and categories WordPress, the WordPress has_term() function reference and WordPress get_post_meta() reference resources cover adjacent angles worth reviewing alongside this guide. They focus on the underlying technology and standards — this post focuses on the match products and categories WordPress decision specifically.

When you revisit your match products and categories WordPress approach in 12 to 24 months, three signals usually indicate a refresh is justified. First, the original brief no longer matches business reality — product, audience, or operational scope has shifted. Second, the underlying technology has moved forward enough that the match products and categories WordPress decision made under previous constraints would be different today. Third, ongoing maintenance overhead has crept up beyond what was forecast at launch. None of these are emergencies on their own; together they signal it is time to revisit fundamentals rather than patch around them.

Why match products and categories in WordPress?

The “match selected products and categories” pattern shows up across most non-trivial WordPress plugins. Real-world examples where this pattern is the heart of the feature:

  • Discount rules — “apply 20% off to products X, Y, Z OR anything in the Sale category”
  • Conditional shipping — “free shipping for cart items in these categories, paid shipping otherwise”
  • Display rules — “show this promotional banner only on posts in the Tutorials category”
  • Membership content gating — “members-only access to products tagged Premium”
  • Bulk actions in plugins — “apply this stock update to all products in these categories”
  • Conditional fields in checkout — “show this delivery-instructions field only if cart contains products in the Furniture category”
  • Email triggers — “send a follow-up email when an order contains products in these categories”

Storing selected product / category IDs in WordPress

WordPress gives you three places to store an array of selected IDs. Pick based on the scope of the data:

StorageScopeBest for
wp_options tableSite-wide singletonGlobal plugin settings (one selection for the whole site)
wp_postmeta tablePer-postCoupons, conditional discount rules, per-post configuration
Custom DB tablePer-record at scaleHigh-volume use cases (1000+ rules); avoids postmeta query overhead

For most plugins, options + postmeta cover everything. Custom tables only become necessary when you have thousands of records OR you need to query the “selected lists” themselves (e.g., “find all rules that include product 42”).

Why wp_json_encode() beats serialize() for storing arrays

You will see two patterns in older WordPress codebases for storing arrays: serialize() and wp_json_encode(). wp_json_encode() is the better default in modern code.

  • Cleaner storage — JSON is shorter and human-readable in the database, making debugging dramatically easier
  • Cross-language compatibility — if your data ever needs to be read by JavaScript or another language, JSON works everywhere
  • Safer with unicodewp_json_encode() handles emoji, accented characters, and special symbols correctly by default; serialize() has multibyte length-counting issues that cause subtle bugs
  • WordPress-awarewp_json_encode() sets the right flags for WordPress (JSON_UNESCAPED_SLASHES + handles encoding), unlike raw json_encode()
  • Auto-handled by update_option — WordPress automatically serializes arrays passed to update_option(), so for simple option storage you can skip the encoding step. But explicit wp_json_encode() + json_decode() is more predictable for cross-platform usage

Reading legacy serialized data: If your plugin has existing data stored via serialize(), do not break it. Use maybe_unserialize() — it correctly handles both serialized strings AND plain values without erroring. New writes should use wp_json_encode(); old reads tolerate both formats.

Save selected products + categories in WordPress

The complete write pattern — save user-selected product and category lists to options or post meta:

// Save selected product IDs + category IDs to wp_options.
// Use wp_json_encode() — it handles unicode, sets the correct flags by default,
// and never silently fails the way serialize() can.

$selected_products   = array( 42, 87, 105 );             // product IDs
$selected_categories = array( 'shoes', 'accessories' );  // category slugs or IDs

update_option( 'raj_selected_products',   wp_json_encode( $selected_products ) );
update_option( 'raj_selected_categories', wp_json_encode( $selected_categories ) );

// Per-post version — stored against a specific post (e.g., a coupon CPT entry)
update_post_meta( $post_id, '_raj_selected_products',   wp_json_encode( $selected_products ) );
update_post_meta( $post_id, '_raj_selected_categories', wp_json_encode( $selected_categories ) );

Retrieve and decode the saved arrays

The complete read pattern — retrieve and decode the lists, with null-safety for the case where the option was never set:

// Retrieve + decode. ALWAYS pass true as the second argument to json_decode()
// so you get an associative array, not a stdClass object. Cast to (array)
// for null-safety if the option was never set.

$selected_products   = (array) json_decode( get_option( 'raj_selected_products' ), true );
$selected_categories = (array) json_decode( get_option( 'raj_selected_categories' ), true );

// From post meta
$selected_products   = (array) json_decode( get_post_meta( $post_id, '_raj_selected_products', true ), true );
$selected_categories = (array) json_decode( get_post_meta( $post_id, '_raj_selected_categories', true ), true );

// If you must read legacy serialized data, use maybe_unserialize() — it handles
// both serialized strings AND plain values without erroring on the wrong format.
$legacy = (array) maybe_unserialize( get_option( 'legacy_option_key' ) );

Common mistake: missing the true flag: json_decode( $string ) without the second argument returns a stdClass object, not an array. in_array() and has_term() then silently fail. Always pass true as the second argument: json_decode( $string, true ).

Match a single product against the selected list

Once you have the array of selected product IDs, checking whether the current product is in it is a one-line in_array() with a strict-type check:

// Match the current product against the stored list of product IDs.
// Cast the second array to (array) so we handle null/empty cases safely.

global $product;
if ( ! $product instanceof WC_Product ) {
    return false;
}

$selected_products = (array) json_decode( get_option( 'raj_selected_products' ), true );
$product_matched   = in_array( $product->get_id(), $selected_products, true );

if ( $product_matched ) {
    // current product is in the user's selected list
}

Always pass true as the third argument to in_array() for strict type comparison. Otherwise in_array( 42, array( "42abc" ) ) returns true due to PHP’s loose-comparison rules — a real bug source.

Match by category with WordPress has_term()

WordPress’s built-in has_term() function checks whether a post (or product) is in any of the given terms within a specified taxonomy. It’s the core function for all category/tag matching:

// Match the current product against the stored list of category slugs/IDs.
// has_term() is the core WP function that checks taxonomy membership.

global $product;
if ( ! $product instanceof WC_Product ) {
    return false;
}

$selected_categories = (array) json_decode( get_option( 'raj_selected_categories' ), true );
$category_matched    = ! empty( $selected_categories )
    && has_term( $selected_categories, 'product_cat', $product->get_id() );

if ( $category_matched ) {
    // current product is in at least one of the selected categories
}

WordPress has_term() — full signature and arguments

Understanding has_term()‘s arguments saves a lot of debugging time. Reference:

// has_term( $term, $taxonomy, $post = null )
//
// $term     — int|string|array — term ID, slug, or array of either.
//             Mixed slugs + IDs in the same array work fine.
// $taxonomy — string           — taxonomy slug ('category', 'product_cat',
//             'post_tag', 'product_tag', or any custom taxonomy)
// $post     — int|WP_Post|null — post ID or post object. Defaults to current
//             global $post if null.
//
// Returns true if the post has ANY of the given terms (OR logic by default).
// Returns false on no match, or WP_Error if the taxonomy is invalid.

// Examples:
has_term( 42, 'category', 123 );             // post 123 in category ID 42
has_term( 'news', 'category', 123 );          // post 123 in "news" category
has_term( array( 'news', 'reviews' ), 'category', 123 ); // either category
has_term( array( 1, 'sale' ), 'product_cat', 99 );        // mixed IDs + slugs

Important: has_term() uses OR logic by default: When you pass an array of terms (array( 1, 2, 3 )), has_term() returns true if the post has ANY of those terms. There is no built-in way to enforce AND (“must have ALL of these terms”). For AND logic across multiple terms, you need to combine multiple has_term() calls with PHP’s && operator.

Combine product + category checks with AND / OR / NOT logic

The real-world matching patterns use boolean combinations of the two checks. The complete logic toolkit:

// Combine product + category matches with AND / OR logic.
// Common use cases: "discount applies to these specific products
// OR to anything in these categories" vs "discount only applies when
// the product is BOTH on the list AND in one of the categories".

$selected_products   = (array) json_decode( get_option( 'raj_selected_products' ),   true );
$selected_categories = (array) json_decode( get_option( 'raj_selected_categories' ), true );

global $product;
$pid = $product->get_id();

$product_match  = in_array( $pid, $selected_products, true );
$category_match = ! empty( $selected_categories )
    && has_term( $selected_categories, 'product_cat', $pid );

// OR logic — qualifies if EITHER condition is true
if ( $product_match || $category_match ) {
    // apply your rule
}

// AND logic — must satisfy BOTH conditions
if ( $product_match && $category_match ) {
    // apply your stricter rule
}

// NOT logic — explicitly excluded
if ( ! $product_match && ! $category_match ) {
    // current product is OUTSIDE the selected scope
}

Match regular WordPress posts (not just WooCommerce products)

has_term() works on any taxonomy, not just product_cat. Use it identically for regular post categories, tags, and custom taxonomies:

// Match a regular WordPress post against selected categories or tags.
// has_term() works on any taxonomy — you just pass the right taxonomy slug.

global $post;
if ( ! $post instanceof WP_Post ) {
    return false;
}

// Match post categories (WP core taxonomy: 'category')
$selected_post_cats = array( 'news', 'tutorials' ); // slugs or IDs
if ( has_term( $selected_post_cats, 'category', $post->ID ) ) {
    // current post is in one of those categories
}

// Match post tags (WP core taxonomy: 'post_tag')
$selected_tags = array( 'wordpress', 'php' );
if ( has_term( $selected_tags, 'post_tag', $post->ID ) ) {
    // current post has one of those tags
}

// Match a custom taxonomy
if ( has_term( array( 'featured', 'editorial' ), 'my_custom_taxonomy', $post->ID ) ) {
    // current post is tagged with the custom taxonomy
}

WordPress core taxonomies use predictable slugs: category for post categories, post_tag for post tags. WooCommerce adds product_cat for product categories and product_tag for product tags. Custom post types you register can define their own taxonomies with arbitrary slugs.

Performance: cache decoded arrays in transients

A common performance trap: calling get_option() + json_decode() inside a loop. On a category archive page rendering 24 products, that’s 24 redundant database queries and decode operations. Cache the decoded result via a transient and invalidate it when the option changes:

// Performance pattern — cache the decoded array via transient so we don't
// re-decode + re-query the database on every page load. Especially useful
// if the matching logic runs inside a loop (e.g., on a category archive
// page with 24 products per page = 24 redundant get_option calls).

function raj_get_selected_products() {
    $cached = get_transient( 'raj_selected_products' );
    if ( false !== $cached ) {
        return $cached;
    }
    $list = (array) json_decode( get_option( 'raj_selected_products' ), true );
    set_transient( 'raj_selected_products', $list, HOUR_IN_SECONDS );
    return $list;
}

// Invalidate the cache when the option is updated.
add_action( 'update_option_raj_selected_products', function () {
    delete_transient( 'raj_selected_products' );
} );

// Use it everywhere instead of the raw get_option call.
$selected_products = raj_get_selected_products();

For frequently-read, rarely-changed selection lists, an object cache (Redis via the Redis Object Cache plugin) makes this even faster — transients backed by object cache live in memory, no database round-trip at all.

Testing match products and categories WordPress logic

Unit tests give you confidence that your matching logic handles edge cases. WP_UnitTestCase + the WooCommerce test helpers cover the standard cases:

// PHPUnit test pattern for the matching logic. WP_UnitTestCase gives you
// the factory helpers for creating posts, products, and terms.

class RajMatchTest extends WP_UnitTestCase {

    public function test_product_matches_selected_list() {
        // Arrange
        $product = WC_Helper_Product::create_simple_product();
        update_option( 'raj_selected_products', wp_json_encode( array( $product->get_id() ) ) );

        // Act
        $selected = (array) json_decode( get_option( 'raj_selected_products' ), true );
        $matched  = in_array( $product->get_id(), $selected, true );

        // Assert
        $this->assertTrue( $matched, 'Product ID should match selected list' );
    }

    public function test_product_matches_selected_category() {
        // Arrange
        $term    = $this->factory->term->create( array( 'taxonomy' => 'product_cat', 'slug' => 'sale' ) );
        $product = WC_Helper_Product::create_simple_product();
        wp_set_object_terms( $product->get_id(), array( $term ), 'product_cat' );

        // Act + Assert
        $this->assertTrue(
            has_term( 'sale', 'product_cat', $product->get_id() ),
            'Product should be in the sale category'
        );
    }
}

Common mistakes when matching products and categories

Patterns that look correct but cause real bugs:

  • Forgetting the true flag on json_decode — returns stdClass object instead of array; in_array() + has_term() silently fail
  • Loose-comparison in_array — without the third true argument, in_array( 42, array( "42abc" ) ) returns true. Always use strict comparison
  • Passing wrong taxonomy slug to has_term — products use product_cat not category; tags use product_tag not post_tag. WP_Error returned silently
  • Not handling null/empty cases — fresh installs, deleted options, deleted posts all yield null values; cast to (array) defensively
  • Mixing IDs and slugs without realizinghas_term() handles both, but in_array() does not; be consistent within a function
  • Storing slugs but checking IDs (or vice versa) — write and read code paths must agree on the format. Pick one, document it, stick with it
  • Querying inside a loop — calling get_option/get_post_meta inside foreach ( $products as $product ) creates N+1 query pattern; cache outside the loop
  • Forgetting cache invalidation — when you cache the decoded array via transient, you must invalidate when the option changes (via the update_option_{key} hook)

Storage + decoding — FAQs

Should I use serialize() or wp_json_encode() for arrays in WordPress?

Use wp_json_encode() for new code. It’s shorter in the database, human-readable, unicode-safe by default, and cross-language compatible. serialize() has subtle multibyte length-counting bugs that produce corrupted data on certain unicode inputs. For reading existing data that may have been stored via either method, use maybe_unserialize() which handles both formats.

Why does my decoded array come back as an object instead of an array?

You forgot the true flag on json_decode(). By default, json_decode( $string ) returns a stdClass object, not an associative array. Pass true as the second argument: json_decode( $string, true ). in_array() and has_term() both fail silently when given an object instead of an array.

When should I use options vs post meta?

Use wp_options for site-wide singletons — one configuration that applies globally. Use wp_postmeta for per-post configuration — for example, a Coupons CPT where each coupon row stores its own list of applicable products. For thousands of records or when you need to query the lists themselves (find rules containing product X), use a custom database table instead.

has_term + matching — FAQs

Why does WordPress has_term() return false even when I can see the term on the post?

Three common causes. (1) Wrong taxonomy slug — products use product_cat not category, tags use product_tag not post_tag. (2) Term ID vs term slug mismatch — has_term() accepts both, but the value type must match how the term was actually applied. (3) Object caching — terms set in the same request may not yet be visible to has_term() until clean_term_cache() runs. Verify by querying wp_get_object_terms() directly to see what terms are actually applied.

How do I check if a product is in ANY of several categories (OR logic)?

Pass an array of term IDs or slugs as the first argument to has_term(). It returns true if the product has ANY of those terms. has_term( array( 1, 2, 3 ), 'product_cat', $product_id ) is the OR pattern. There’s no native AND pattern — for “must be in ALL of these categories”, combine multiple has_term() calls with PHP’s && operator.

How do I match products by attribute (not category)?

WooCommerce stores product attributes as a custom taxonomy with slug pa_{attribute-name}. For a “color” attribute, the taxonomy slug is pa_color. Use has_term( 'blue', 'pa_color', $product_id ). For free-text attribute values (not taxonomy-backed), use $product->get_attribute( 'pa_color' ) instead and string-match the result.

Performance — FAQs

Should I cache the decoded array?

Yes if the matching logic runs inside loops or on every request. get_option() is fast (cached in WordPress’s object cache automatically), but json_decode() still costs CPU on every call. For frequently-read selection lists, store the DECODED array in a transient so you skip the decode step too. Invalidate the transient via the update_option_{key} hook when the source option changes.

How do I avoid N+1 queries when matching many products at once?

Read the selection lists ONCE outside any loop. Pre-fetch term IDs/slugs into a lookup array. Use wp_get_object_terms() with an array of post IDs to fetch terms for many products in one query, rather than calling has_term() per-product. For very high-volume scenarios (admin reports across thousands of products), drop to direct $wpdb queries that JOIN wp_term_relationships and wp_term_taxonomy manually.

Does has_term() use the WordPress object cache?

Yes — has_term() internally calls wp_get_object_terms() which uses the WordPress object cache. So repeated calls for the same post ID within a single request are fast. However, every NEW post ID is a fresh cache miss. For loops over many distinct posts, the cache misses add up; bulk-fetch terms via wp_get_object_terms() with an array of post IDs to mitigate.

What is the most important factor in match products and categories WordPress?

The single most important factor in match products and categories WordPress is matching the project scope to the right delivery model. match products and categories WordPress done by the wrong team type can cost 3-5x more than necessary; match products and categories WordPress done by the right team is predictable, bounded, and produces measurable value. Run an honest scope discovery before committing to any match products and categories WordPress engagement, and insist on detailed deliverables in the SOW so both sides are aligned on what success looks like.

Need a WordPress plugin built right, with proper conditional logic and matching patterns?

Leave a Reply