Back to Tips & Tricks

JetSmartFilters Hierarchical Filter Not Working for Brand and Model

BoostMyCroco TeamPublished on
Abstract tree of parent and child nodes feeding two linked dropdown shapes, illustrating a brand to model hierarchical filter

Is your JetSmartFilters hierarchical filter not working, with the second dropdown listing models from other brands? The cause is how your data is structured, not a bug. When level 1 uses a Brand taxonomy and level 2 uses a separate Model taxonomy, level 2 shows every model term found on products that also have the chosen brand. Filter hierarchy works only with taxonomies, so it can't follow a JetEngine Relation between brands and models. The fix is one hierarchical taxonomy, with brands as parent terms and models as child terms, used on both filter levels.

The symptom: level 2 shows other brands' models

Take a brake pad set that fits Model A1 from Brand A and Model B2 from Brand B. With two flat taxonomies, the product gets Brand A and Brand B as brands, plus Model A1 and Model B2 as models.

A shopper picks Brand A and expects only Brand A's models. Level 2 lists Model A1, Model A2 and Model B2. The more shared parts you sell, the more foreign models show up in each brand's list.

Why the filter lists the wrong models

When the two levels use two different taxonomies, level 2 is built from the terms that share posts with the term picked in level 1. Crocoblock's hierarchical filter tutorial uses Country and City, and explains that its example works because all posts with French city terms also have the France term. Only shared posts link a city to its country. Your brake pad has the Brand A term, so every model term on it counts as a Brand A model, Model B2 included.

A JetEngine Relation doesn't change this. Filter hierarchy is available only for the Select filter type and only for taxonomies, so the levels can't read a Brand → Model mapping stored in a Relation. JetSmartFilters can still narrow results by a relation: a Select filter with the Posts data source can use the JetEngine: filters children items list by parents IDs dynamic tag as its Query Variable, as shown in How to Filter Related Items. That filters the listing, but it doesn't build dependent dropdowns. Relation-based hierarchical filtering is a long-standing request (Crocoblock suggestion #770), and Crocoblock's current documentation still describes filter hierarchy as taxonomy-only.

The fix: one parent/child taxonomy

A hierarchical Select filter doesn't need multiple taxonomies. You can use the same taxonomy on every level, and each Select then stands for one term level (parent, then child). With brand and model in one taxonomy, a model's parent term ties it to its brand, and shared products no longer leak into the dropdown.

  1. Go to WordPress Dashboard > JetEngine > Taxonomies > Add New.
  2. Fill in Taxonomy Name (for example Vehicle) and Taxonomy Slug (for example vehicle).
  3. In Post Type, choose the post type your results show: Products for WooCommerce, or your JetEngine CPT.
  4. Open Advanced Settings and enable Hierarchical.
  5. Click Add Taxonomy.

Create each brand as a top-level term and each model as a term with its brand set as the parent, or use the migration snippet below for a large catalogue. Keep the taxonomy attached to the same post type your listing shows, or the filter returns nothing or the wrong items.

Tagging parts that fit several brands

Give each product every model it fits, even when those models sit under different brands. The brake pad gets Brand A > Model A1 and Brand B > Model B2 in the Vehicle taxonomy. Level 2 now lists the child terms of the selected brand, not every term found on matching products, so Brand A offers only Brand A's models while the pad still appears for both Model A1 and Model B2.

Assign the parent brand as well as the child model. Whether a brand-only selection also returns products that have only child terms depends on how the query handles child terms, so check it in your version. The migration snippet assigns both by default.

Build the hierarchical Select filter

  1. Open the JetSmartFilters admin menu (SmartFilters) and click Add New.
  2. Enter a Filter Name, for example Vehicle.
  3. Set Filter Type to Select and turn on Is hierarchical.
  4. Click Add New next to the Filter hierarchy label. Set Label to Brand, Placeholder to Select brand, and Taxonomy to Vehicle.
  5. Click Add New again. Set Label to Model, Placeholder to Select model, and Taxonomy to Vehicle again.
  6. Adjust the Filters Labels section if needed, then click Update.

Level order matters: the first level is the main one, and each later level depends on the option picked before it. You can add more levels, for example model generations as grandchild terms.

Add the filter to your page

  1. Edit the archive, shop or listing page in Elementor, the block editor or Bricks.
  2. Add the Select Filter widget (or block) and choose your Vehicle filter.
  3. Set This filter for to the provider that shows your results. For a JetEngine Listing Grid, choose JetEngine.
  4. Optional: enable the Indexer in the Indexer Options tab.

If the page also has a category filter, see hiding the WooCommerce Uncategorized category in JetSmartFilters. If your Listing Grid uses a custom query and the filter has no effect, go through these JetEngine Query Builder mistakes and fixes.

Move an existing catalogue into the new taxonomy

If your products already have terms from a flat Brand taxonomy and a flat Model taxonomy, the snippet below copies them into the new taxonomy. It:

  • creates brands as parent terms and models as child terms, copying the names of your old terms and reusing any matching term already under the same parent;
  • reports, instead of creating, any slug that is missing from the old taxonomy or already used under another parent in the new one;
  • adds each product's new model terms (and, by default, the parent brand) in batches, plus the brand term for any old brand with no mapped model on that product;
  • shows problems on every batch screen and run totals on the Done screen;
  • never edits or deletes the old taxonomies.

Fill $map with the slugs of your old Brand and Model taxonomy terms, not the slugs of relation posts. Use your JetEngine relation only as a guide to which model belongs to which brand. Start from an empty Vehicle taxonomy if you can, and export your database first, because the snippet adds terms to every matching product.

Add this with a code snippets plugin (run in the admin area) or in your child theme's functions.php file, then edit the settings at the top:

Code

/**
 * BoostMyCroco: one-time migration from two flat taxonomies (brand + model)
 * into one hierarchical taxonomy (brand = parent term, model = child term).
 *
 * 1. Fill in the settings and the $map below, using OLD taxonomy term slugs.
 * 2. As an administrator, open /wp-admin/?bmc_migrate_vehicle=1
 * 3. Click "Start migration", then "Next batch" until you see "Done".
 * 4. Remove this snippet.
 *
 * The old taxonomies are never changed or deleted.
 */
add_action( 'admin_init', 'bmc_migrate_vehicle_taxonomy' );

function bmc_migrate_vehicle_taxonomy() {
	if ( ! isset( $_GET['bmc_migrate_vehicle'] ) ) {
		return;
	}

	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( 'You are not allowed to run this migration.' );
	}

	// ---- Settings -------------------------------------------------------
	$new_tax        = 'vehicle'; // New JetEngine taxonomy with Hierarchical enabled.
	$old_brand_tax  = 'brand';   // Old flat brand taxonomy.
	$old_model_tax  = 'model';   // Old flat model taxonomy.
	$post_type      = 'product'; // Or your JetEngine CPT slug.
	$batch_size     = 100;
	$assign_parents = true;      // Also add the brand (parent) term with each model.

	// Old brand term slug => old model term slugs. Take them from the old taxonomies'
	// term lists, not from relation posts. Use your JetEngine relation only as a guide.
	$map = array(
		'brand-a' => array( 'model-a1', 'model-a2' ),
		'brand-b' => array( 'model-b1', 'model-b2' ),
	);
	// ---------------------------------------------------------------------

	$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';

	if ( ! wp_verify_nonce( $nonce, 'bmc_migrate_vehicle' ) ) {
		$start = wp_nonce_url(
			add_query_arg( array( 'bmc_migrate_vehicle' => 1, 'bmc_offset' => 0 ), admin_url() ),
			'bmc_migrate_vehicle'
		);
		wp_die(
			'<p>Export your database, empty or restore the product trash, and move products out of custom statuses before you start.</p><p><a href="' . esc_url( $start ) . '">Start migration</a></p>',
			'Vehicle taxonomy migration',
			array( 'response' => 200 )
		);
	}

	foreach ( array( $new_tax, $old_brand_tax, $old_model_tax ) as $tax ) {
		if ( ! taxonomy_exists( $tax ) ) {
			wp_die( 'Taxonomy not found: ' . esc_html( $tax ) );
		}
	}

	if ( ! is_taxonomy_hierarchical( $new_tax ) ) {
		wp_die( 'Enable Hierarchical for the new taxonomy in JetEngine first.' );
	}

	if ( ! is_object_in_taxonomy( $post_type, $new_tax ) ) {
		wp_die( 'Attach the new taxonomy to ' . esc_html( $post_type ) . ' first.' );
	}

	$offset = isset( $_GET['bmc_offset'] ) ? absint( $_GET['bmc_offset'] ) : 0;
	$errors = array();

	// Step 1: build the Brand > Model tree on the first batch and keep it in a transient.
	// Existing terms under the same parent are reused, so rebuilding is safe.
	$tree = ( $offset > 0 ) ? get_transient( 'bmc_vehicle_tree' ) : false;

	if ( ! is_array( $tree ) || ! isset( $tree['models'], $tree['brands'], $tree['parent_of'] ) ) {
		$tree = array(
			'models'    => array(), // Old model slug => new model term IDs.
			'brands'    => array(), // Old brand slug => new brand term ID.
			'parent_of' => array(), // New model term ID => new brand term ID.
		);

		foreach ( $map as $brand_slug => $model_slugs ) {
			$brand_slug = sanitize_title( $brand_slug );
			$brand_id   = bmc_get_or_create_term( $brand_slug, $old_brand_tax, $new_tax, 0, $errors );

			if ( ! $brand_id ) {
				$errors[] = 'All models of ' . $brand_slug . ' were skipped because its brand term could not be set up.';
				continue;
			}

			$tree['brands'][ $brand_slug ] = $brand_id;

			foreach ( (array) $model_slugs as $model_slug ) {
				$model_slug = sanitize_title( $model_slug );
				$model_id   = bmc_get_or_create_term( $model_slug, $old_model_tax, $new_tax, $brand_id, $errors );

				if ( ! $model_id ) {
					continue;
				}

				$tree['models'][ $model_slug ][] = $model_id;
				$tree['parent_of'][ $model_id ]  = $brand_id;
			}
		}

		// Keep setup problems so every batch screen and the Done screen show them.
		$tree['errors'] = $errors;
		set_transient( 'bmc_vehicle_tree', $tree, HOUR_IN_SECONDS );
	} elseif ( $offset > 0 && ! empty( $tree['errors'] ) ) {
		$errors = array_merge( $tree['errors'], $errors );
	}

	// Unmapped slug totals for the whole run, kept between batches.
	$missing_totals = ( $offset > 0 ) ? get_transient( 'bmc_vehicle_missing' ) : array();

	if ( ! is_array( $missing_totals ) ) {
		$missing_totals = array();
	}

	// Step 2: add the new terms to one batch of products.
	$product_ids = get_posts(
		array(
			'post_type'        => $post_type,
			'post_status'      => 'any', // Excludes trash, auto-draft and custom statuses hidden from search.
			'posts_per_page'   => $batch_size,
			'offset'           => $offset,
			'orderby'          => 'ID',
			'order'            => 'ASC',
			'fields'           => 'ids',
			'no_found_rows'    => true,
			'suppress_filters' => true,
		)
	);

	$updated     = 0;
	$brand_added = 0;
	$missing     = array(); // 'taxonomy: slug' => product IDs in this batch with no new term.

	foreach ( $product_ids as $product_id ) {
		$old_models = wp_get_object_terms( $product_id, $old_model_tax, array( 'fields' => 'slugs' ) );
		$old_brands = wp_get_object_terms( $product_id, $old_brand_tax, array( 'fields' => 'slugs' ) );
		$old_models = is_wp_error( $old_models ) ? array() : $old_models;
		$old_brands = is_wp_error( $old_brands ) ? array() : $old_brands;

		$new_ids = array();
		$covered = array(); // Brand term IDs already represented by one of this product's models.

		foreach ( $old_models as $old_slug ) {
			if ( ! isset( $tree['models'][ $old_slug ] ) ) {
				$missing[ $old_model_tax . ': ' . $old_slug ][] = $product_id;
				continue;
			}

			foreach ( $tree['models'][ $old_slug ] as $model_id ) {
				$new_ids[] = (int) $model_id;
				$brand_id  = isset( $tree['parent_of'][ $model_id ] ) ? (int) $tree['parent_of'][ $model_id ] : 0;

				if ( $brand_id ) {
					$covered[] = $brand_id;

					if ( $assign_parents ) {
						$new_ids[] = $brand_id;
					}
				}
			}
		}

		// An old brand with no mapped model on this product (for example a part that fits
		// every model of that brand): add the brand term so it stays in that brand's results.
		$added = false;

		foreach ( $old_brands as $old_slug ) {
			if ( ! isset( $tree['brands'][ $old_slug ] ) ) {
				$missing[ $old_brand_tax . ': ' . $old_slug ][] = $product_id;
				continue;
			}

			$brand_id = (int) $tree['brands'][ $old_slug ];

			if ( ! in_array( $brand_id, $covered, true ) ) {
				$new_ids[] = $brand_id;
				$covered[] = $brand_id;
				$added     = true;
			}
		}

		if ( $added ) {
			$brand_added++;
		}

		if ( empty( $new_ids ) ) {
			continue;
		}

		// Append = true keeps any Vehicle terms the product already has.
		$result = wp_set_object_terms( $product_id, array_values( array_unique( array_map( 'intval', $new_ids ) ) ), $new_tax, true );

		if ( is_wp_error( $result ) ) {
			$errors[] = 'Item ' . $product_id . ': ' . $result->get_error_message();
			continue;
		}

		$updated++;
	}

	// One line per slug with no new term in this batch, and add it to the run totals.
	foreach ( $missing as $label => $ids ) {
		$errors[] = 'No new term for ' . $label . ' (not in $map, or skipped by a problem above; ' . count( $ids ) . ' items in this batch, e.g. IDs ' . implode( ', ', array_slice( $ids, 0, 5 ) ) . ')';

		$missing_totals[ $label ] = ( isset( $missing_totals[ $label ] ) ? (int) $missing_totals[ $label ] : 0 ) + count( $ids );
	}

	$html = '<p>Offset ' . (int) $offset . ': ' . count( $product_ids ) . ' items checked, ' . (int) $updated . ' updated, ' . (int) $brand_added . ' given a brand term with no mapped model of that brand.</p>';

	if ( $errors ) {
		$html .= '<ul><li>' . implode( '</li><li>', array_map( 'esc_html', $errors ) ) . '</li></ul>';
	}

	if ( count( $product_ids ) < $batch_size ) {
		delete_transient( 'bmc_vehicle_tree' );
		delete_transient( 'bmc_vehicle_missing' );
		$html .= '<p><strong>Done.</strong> Remove this snippet now.</p>';

		if ( $missing_totals ) {
			$lines = array();

			foreach ( $missing_totals as $label => $count ) {
				$lines[] = $label . ': ' . (int) $count . ' items in total';
			}

			$html .= '<p>Slugs with no new term across the whole run:</p><ul><li>' . implode( '</li><li>', array_map( 'esc_html', $lines ) ) . '</li></ul>';
		}
	} else {
		set_transient( 'bmc_vehicle_missing', $missing_totals, HOUR_IN_SECONDS );

		$next = wp_nonce_url(
			add_query_arg( array( 'bmc_migrate_vehicle' => 1, 'bmc_offset' => $offset + $batch_size ), admin_url() ),
			'bmc_migrate_vehicle'
		);
		$html .= '<p><a href="' . esc_url( $next ) . '">Next batch</a></p>';
	}

	wp_die( $html, 'Vehicle taxonomy migration', array( 'response' => 200 ) );
}

/**
 * Lowercases text, multibyte-safe when mbstring is available.
 */
function bmc_lower( $text ) {
	return function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text );
}

/**
 * Returns the ID of a term with this slug (or name) under $parent_id in $target_tax,
 * creating it if needed with the name of the old term.
 * A slug missing from the old taxonomy, or already used under another parent, is reported.
 */
function bmc_get_or_create_term( $slug, $source_tax, $target_tax, $parent_id, &$errors ) {
	$slug      = sanitize_title( $slug );
	$parent_id = (int) $parent_id;
	$source    = get_term_by( 'slug', $slug, $source_tax );

	// The slug must exist in the old taxonomy, so products can be matched and the real name copied.
	if ( ! ( $source instanceof WP_Term ) ) {
		$errors[] = $slug . ': not found in ' . $source_tax . ', check the slug in $map.';
		return 0;
	}

	$name = $source->name;

	// 1. Reuse a term with the same slug or name under the same parent.
	$siblings = get_terms(
		array(
			'taxonomy'   => $target_tax,
			'hide_empty' => false,
			'parent'     => $parent_id,
		)
	);

	if ( ! is_wp_error( $siblings ) ) {
		foreach ( $siblings as $sibling ) {
			if ( $sibling->slug === $slug || bmc_lower( $sibling->name ) === bmc_lower( $name ) ) {
				return (int) $sibling->term_id;
			}
		}
	}

	// 2. The slug exists under a different parent: report it instead of creating a near-duplicate.
	$existing = get_term_by( 'slug', $slug, $target_tax );

	if ( $existing instanceof WP_Term ) {
		$errors[] = $slug . ': already exists in ' . $target_tax . ' under another parent (term ID ' . (int) $existing->term_id . '). Move or delete it, then start again.';
		return 0;
	}

	$result = wp_insert_term( $name, $target_tax, array( 'slug' => $slug, 'parent' => $parent_id ) );

	if ( is_wp_error( $result ) ) {
		// WordPress refuses a duplicate name under the same parent and returns the existing ID.
		if ( 'term_exists' === $result->get_error_code() && $result->get_error_data() ) {
			return (int) $result->get_error_data();
		}

		$errors[] = $slug . ': ' . $result->get_error_message();
		return 0;
	}

	return (int) $result['term_id'];
}

How to run it

  1. In the Settings block, set $new_tax to the new taxonomy slug, $old_brand_tax and $old_model_tax to your old flat taxonomies, and $post_type to product or your CPT slug.
  2. Replace the example $map with the slugs of your old Brand and Model terms, as shown in each old taxonomy's term list. Don't paste relation post slugs: they often differ from the term slugs and will be reported as not found. List every brand, even one with no models.
  3. Check product statuses. The snippet uses post_status any, which excludes trash, auto-draft and custom statuses hidden from search (some workflow plugins register these). Products in those statuses keep only their old terms, so empty or restore the trash and move such products to a normal status before you run it, or tag them by hand afterwards.
  4. While logged in as an administrator, open yoursite.com/wp-admin/?bmc_migrate_vehicle=1 and click Start migration.
  5. Click Next batch until the screen says Done. Don't add, delete or import products during the run: batches page through products by offset, so changes mid-run can make it skip items.
  6. Delete or deactivate the snippet.

How it works

Nothing runs unless the flag is in the URL, the user has manage_options and the link's nonce is valid. The snippet also stops if a taxonomy is missing, if the new one isn't hierarchical, or if it isn't attached to $post_type.

The first batch builds the term tree and stores it, together with any setup problems, in a transient. Those problems appear again on every batch screen and on the Done screen. If a brand can't be set up, all of its models are skipped, and their products are listed as having no new term. Fix the reported slug in $map, or move or delete the conflicting Vehicle term, then run again from the start. Existing terms are reused, so reruns don't create duplicates.

Terms are added with wp_set_object_terms() in append mode, so nothing a product already has is removed. Lower $batch_size if you hit time-outs.

Test checklist

  1. Pick Brand A. Level 2 lists only Brand A's models.
  2. Switch to Brand B. Brand A's models disappear and Brand B's appear.
  3. Pick a model that shares a part with another brand, then the matching model under the other brand. The shared part shows up both times.
  4. Pick a brand without a model. Products that had only an old brand term appear. If products with only child terms are missing, check that $assign_parents is true and run the snippet again from the start.
  5. Clear the filter. The full catalogue comes back.
  6. Open a product in the editor. It has its new Vehicle terms and still has its old Brand and Model terms.

If you need to keep the JetEngine relation

Keep it for related-items listings and anything else that uses it. Treat the relation as your guide to which model belongs to which brand, and the Vehicle taxonomy as what shoppers filter by. When you add models, add them to the taxonomy too, or update the map and run the snippet again; it only adds what is missing.

Common problems

  • Level 2 still shows other brands' models. One level still uses the old Model taxonomy. Both levels must use Vehicle.
  • You can't choose a parent for a model. Edit the taxonomy in JetEngine > Taxonomies and enable Hierarchical under Advanced Settings.
  • The filter returns nothing. The taxonomy isn't attached to the post type your listing shows, or This filter for points to a different provider than the widget showing the results.
  • The migration reports slugs as not found. $map contains relation post slugs or typos. Copy the slugs from the old taxonomies' term lists.
  • A model shows at the top level or twice under one brand. A term was created by hand before the migration. Merge or move it in the Vehicle term list, then re-run the snippet.
  • Level 2 doesn't update or options look stale. If you use the Indexer, re-index the filter data and test with the Indexer on and off; check how your version handles hierarchical levels.

Once it works, you can send visitors straight to pre-filtered results with a link that filters content by two criteria.

One parent/child taxonomy turns the brand → model link into something the filter hierarchy can read, so level 2 lists only the chosen brand's models. If you'd like the taxonomy, migration and filters set up for you, post a request on BoostMyCroco.

Frequently Asked Questions

Can JetSmartFilters use a JetEngine Relation to fill a dependent dropdown?
Not for chained levels. Once Is hierarchical is on, each level only offers a taxonomy, so a Relation can't decide which models appear. Store the brand to model link as parent and child terms in one hierarchical taxonomy instead.
Can I make a Make, Model and Year filter with more than two levels?
Yes. Add a third level next to Filter hierarchy and point it at the same taxonomy, with years (or generations) created as child terms of each model. Each level then shows the children of the term picked in the level above it.
Do Checkboxes or Radio filters support the multi-level Filter hierarchy option?
No. The multi-level Filter hierarchy, where each dropdown depends on the one before it, is available only for the Select filter type. If you need chained choices, use a hierarchical Select filter.
Can I delete the old Brand and Model taxonomies after migrating?
Only after the new filter has passed your tests and nothing else uses the old taxonomies. Check other filters, listing templates, dynamic tags and conditions first. Keeping them for a while costs nothing and lets you switch back quickly.

This guide answers a question from our community: Filtro Gerarchico con database relazionale uno a molti

Sources

  1. How to Create a Hierarchical Filter — JetSmartFilters (crocoblock.com)
  2. How to Filter Related Items (crocoblock.com)
  3. Hierarchical filtering posts using JetEngine relationship and JetSmartFilters · Issue #770 · Crocoblock/suggestions · GitHub (github.com)

Related tips