Language

Get in touch

WordPress plugin · since 2014

MDTF

Faceted search for WordPress posts and custom post types by post meta fields and taxonomies at the same time.

First release
Active installs
1,000+
Rating
4.5 / 5 (28)Read reviews
  • PHP
  • WordPress
  • WP_Query
  • meta_query
  • tax_query
  • Transients API
  • PHP sessions
  • MySQL
  • PDO
  • jQuery
  • ion.rangeSlider
  • WooCommerce
  • WPML
View on wordpress.org wordpress.org

About

MDTF filters any post type by meta fields and taxonomies together. It was built for catalogs that are not shops: real estate, car dealers, directories. The site owner defines filter fields in a built-in constructor (checkbox, drop-down, range slider, range drop-down, calendar, text input, label), fills them per post, and places search forms as widgets or shortcodes.

The hard part is that WordPress stores meta as untyped strings in wp_postmeta. Every range, date and text condition has to be turned into a meta_query that compares correctly, every visible option needs a live count under the current search, and AND/OR between groups must never weaken the conditions that hide posts.

The plugin has been maintained since 2014, through PHP 7.x and 8.x, WooCommerce 3.0 storage changes, the block widgets editor, and a long series of security reports handled through Patchstack and Wordfence. The premium and free branches share the same code base.

MDTF · meta data filter

what was solved

LIVEsince 2014

Search state and links

shipped · 2014

What it givesSearch result links stay short and can be bookmarked or shared. Repeating the same search reuses one link instead of piling up stored copies.

Problem
A search over many meta fields and taxonomies is a large nested structure. Carried in GET parameters it makes very long links and exposes the query shape, and every query of the page (results, counters, pagination, sorting, ajax recount) has to read the same state.
Solution

The form state is encoded as JSON, stored on the server in a transient or a PHP session, and the link carries only a short key. One accessor decodes and validates it for all queries of the request.

Stack
  • PHP
  • Transients API
  • PHP sessions
  • admin-ajax
Tasks · 5
Search state is too large to travel in the URLStore base64 JSON on the server, put only a short page_mdf key into the link

encode_search_get_params() receives the serialized form over ajax, parses and sanitizes it, removes '~' (any) values, and stores the rest as base64 JSON with set_page_mdf_session(). Depending on the setting keep_search_data_in the data goes to a transient for one day or to $_SESSION. The response is mdf_cat=...&page_mdf=<key>, which the front end turns into the results link.

The same search created a new stored copy every timeKey is md5 of normalized JSON: assoc keys sorted, scalars trimmed, lists kept in order

get_search_data_key() hashes the output of normalize_search_data(), which walks the array recursively, sorts associative keys and keeps list order. Field order from the browser no longer matters, so one search maps to one transient and one link. Links with older keys keep working while their transient lives.

A public endpoint stores every search for a dayReject non-string or oversized input (filterable, 65536 bytes) before anything is stored

mdf_encode_search_get_params is registered for logged-out visitors, and each distinct search writes a transient that lives for a day. The handler checks that vars is a string within mdf_search_data_max_length and answers mdf_cat=-1 without storing otherwise. A supplied key is accepted only if it matches /^[0-9a-f]{32}$/, otherwise the key is derived from the data.

Stored state is untrusted when it is read backDecode to an array or to nothing; force from/to range edges to numbers

get_page_mdf_data() treats anything that does not decode to an array as no search. It merges default widget options, and for every medafi_ field casts 'from' and 'to' to numbers, so range and calendar values reach meta_query as numbers whatever the visitor sent. The decoded result is kept in a static variable for the rest of the request.

Fixed searches need permanent links that never expireA numeric key resolves to page_mdf_string post meta instead of a transient

Constant links are a separate hidden post type. Its save_post() stores the encoded search in page_mdf_string. get_page_mdf_session() checks the key: numeric means a post ID and reads the stored meta, anything else goes to the transient or session store. Results, counters and templates do not need to know which kind of link was used.

Meta query construction

shipped · 2014

What it givesVisitors can search by price, year, mileage, dates or words, including open ranges like 'and more', and filters can reuse data already entered on posts.

Problem
Meta values are strings in wp_postmeta. Prices, years, mileage, date ranges and free text must become meta_query clauses that compare as numbers or dates, support open ranges, and still let MySQL run them.
Solution

Each filter item type has its own translation into meta_query or into a posts_where fragment, with DECIMAL comparisons for sliders and range drop-downs, open upper bounds and two-key range models stored per post.

Stack
  • PHP
  • WP_Meta_Query
  • posts_where
  • MySQL
Tasks · 5
Slider at its maximum must mean 'and more', not 'up to max'Replace the top edge with 2147483647 when the handle sits at the configured max

In get_meta_query_array() the slider case compares the selected 'to' with the slider's own max; if they are equal, the upper bound becomes MetaDataFilterCore::$max_int, so posts with values above the configured scale still match. Range drop-downs use the same idea with an 'i' (infinity) suffix such as '1001-i'. Single sliders and range drop-downs use BETWEEN with type DECIMAL.

Item has a value range, visitor searches one numberDouble-value slider: key <= v AND key_to >= v over two meta fields

For sliders with slider_range_value = 1 a post stores two values, the field key and key_to (for example a price or size span). The visitor picks one value, and the query requires it to fall inside the post's own span, both sides typed DECIMAL.

Date ranges where either calendar end may be emptyFour branches on _from/_to keys: open start, open end, single day, BETWEEN

The calendar item stores <key>_from and <key>_to. Only 'to' set gives _to <= to; only 'from' gives _from >= from; equal values match items whose span covers that day; a proper span becomes BETWEEN on _from. A code comment dates the last branch to 08-10-2014.

Text input searching title or content word by wordposts_where adds per-word LIKE via $wpdb->prepare and esc_like, joined by a filter

A text item can target its own meta value or post_title, post_content, or both with OR or AND. For the post columns the condition is passed to MDTF_HELPER filters on posts_where, which split the phrase into words (switchable with mdf_txtsearch_some_words_behavior) and join them with mdf_txtsearch_some_words_condition, AND by default. Repeated spaces are collapsed first.

Filter fields must reuse meta that already exists on the siteReflected items map a filter key to an existing meta key at query time

A field marked is_reflected carries reflected_key. The query builder, the OR splitter and the public get_field() API all use the reflected key, so a filter can work on meta written by the theme or another plugin without copying values into medafi_ keys.

AND/OR logic between filter groups

in the product

What it givesVisitors can choose whether results match any or all of the options they tick. Hidden, out-of-stock or unrelated items still stay out of results.

Problem
Visitors may want OR between checked options, but a plain OR at the top of meta_query also ORs the service conditions: filter category, hidden posts, WooCommerce visibility and stock. Hidden and foreign posts then leak into results. The chosen mode also has to reach every query of a search.
Solution

Conditions are split into visitor conditions that may be ORed and required ones. The final query is AND on the top level with one nested OR group, for meta and for taxonomies. The mode travels inside the stored search state.

Stack
  • PHP
  • WP_Meta_Query
  • WP_Tax_Query
Tasks · 6
A plain OR would also OR the conditions that hide postswrap_meta_query(): AND on top, visitor OR group nested, service conditions required

wrap_meta_query() builds relation AND and nests the ORable conditions as one relation OR group, only when there are two or more. mdf_hide_post NOT EXISTS, meta_data_filter_cat, _visibility and _stock_status go on the top level and always apply. The same wrapper is used by the results query and by the counters.

Only checkbox and label conditions make sense under ORsplit_meta_query_array() types each clause by its filter item; sliders stay required

The splitter maps each search key (own or reflected) to its item type from the filter sections in the search. Clauses of checkbox and label items become ORable; sliders, drop-downs, text inputs, calendars and range drop-downs stay required. In AND mode the array is returned unchanged, so existing AND searches produce the same SQL as before.

Taxonomy OR must not drop visibility and extra taxonomiessplit_tax_query_array() separates visitor terms from filter-added ones by identity

After third-party filters (mdf_filter_taxonomies, additional taxonomies, product_visibility) have modified the tax_query, the splitter compares each clause with the list built from the form using strict array_search. Matches are visitor conditions, the rest is required. Repeated visitor clauses that the filter collected per request are dropped. wrap_tax_query() then nests only the visitor part under OR.

The AND/OR mode must reach recount, pagination and sortingStore the mode in page_mdf widget options, use the request only as fallback

encode_search_get_params() writes meta_data_filter_bool and mdf_tax_bool into mdf_widget_options of the stored search, taking the visitor's choice when the widget allows both. get_meta_data_filter_bool() and get_mdf_tax_bool() read the stored value first and normalize anything that is not 'OR' to 'AND'. Results, counters, ajax recount, pagination and sorting read the same state.

Enabling taxonomy OR must not change results of widgets already on live sitesApply taxonomy OR only for widgets saved with a version flag; older stay AND

Widgets saved by older versions have always searched taxonomies with AND, whatever their OR option said. Applying the option to them would change results on live sites without any action by the owner. get_widget_tax_bool() honors the option only when taxonomies_options_behaviour_ver is set, which happens when the widget is saved by the new version.

A checked parent and its checked child: the parent would match everything below itDrop a checked term when one of its children is also checked

tax_query includes children by default, so a checked parent would match every post under it and make the child selection meaningless. get_tax_query_array() removes a checked term if get_term_children() of it intersects the checked set, then reindexes the list.

Dynamic counters and caching

shipped · 2014

What it givesEach option shows how many items it would return, so visitors avoid empty results. Counts are cached, so the site stays fast.

Problem
Every checkbox, drop-down option and term shows how many posts it would return under the current search. That is one query per visible item, with joins on wp_postmeta, on every page view and every ajax recount.
Solution

Counting queries fetch IDs only and skip found-rows and cache priming. Counts can be cached in a key/value table keyed by a hash of the query, and term trees in transients with a set lifetime.

Stack
  • PHP
  • WP_Query
  • MySQL
  • Transients API
Tasks · 5
A count must show the search plus this one optionPer item: add its clause, replace the sibling value of the same select, rerun

get_item_posts_count() starts from the current search. A checkbox adds key = 1 if it is not already there. A drop-down option first removes the clause of the same key, because sibling options exclude each other, then adds itself (as BETWEEN for range options). For terms, get_tax_count() injects the term into a copy of the taxonomy state with the per-request tax cache switched off and restores it after.

Counting queries paid for data they never usedfields=ids, nopaging, no meta/term cache priming, set_found_posts() overridden

Counter queries ask only for IDs with update_post_meta_cache, update_post_term_cache and cache_results off. WP_QueryMDFCounterIn extends WP_Query and overrides set_found_posts() to do nothing, so WordPress does not run the extra found-rows step; post_count is the answer.

Same counts recomputed on every page viewCache counts in {prefix}mdf_query_cache keyed by md5 of the query args

WP_QueryMDFCounter hashes the full argument array and looks it up in a two-column table with an index on mkey; on a miss it runs the query and inserts the count. The settings page creates the table and switches the option off with the CREATE statement shown if the database user lacks the privilege. An admin clears the cache from the settings page, which runs a TRUNCATE behind manage_options and a nonce.

Hierarchical term lists cost one query per levelOne get_categories() call per taxonomy, tree built in PHP, cached as transient

get_terms() loads all terms of the taxonomy once and assemble_terms_childs() builds the child lists from that array. With cache_terms_data on, the result is stored under mdf_terms_cache_<md5 of arguments> as a transient whose lifetime comes from the terms cache period setting (hourly to 7 days; no expiry when no period is set).

The filter should not load on pages that have no searchOptional URL allow-list: skip front-end init when the path is not listed

is_should_init() runs before the plugin registers its post types and hooks. If init_on_pages_only is set, it normalizes the current URL (query string and /page/N removed) and returns early when the URL is not in the list. Admin requests always initialize.

Data model and WordPress integration

shipped · 2014

What it givesResults appear in the site's existing design, and staff can rename or reorganize filter fields without losing data already saved on posts.

Problem
Filter fields are defined by the site owner, their values live in post meta of arbitrary post types, and results have to appear in the theme's own templates without changing the theme.
Solution

Field definitions live in a hidden post type. Post values are saved twice, as one array for editing and as separate meta keys for querying. The main query is replaced from pre_get_posts with a re-entrancy guard.

Stack
  • PHP
  • WordPress hooks
  • WP_Query
  • WooCommerce
  • WPML
Tasks · 6
Meta values must be editable as a set and queryable one by oneSave the set as one array and each medafi_ field as its own meta key

save_post() writes page_meta_data_filter as a single array, which the edit screen reads back, and then writes each medafi_ field with update_post_meta(), which meta_query can use. Filter sections and their items are stored as html_items meta of the hidden meta_data_filter post type.

Changing a post's filter category must not leave stale valuesOn category change delete all medafi_ meta of the post before saving new ones

Fields of the old filter category would otherwise stay in wp_postmeta and match searches of that category. save_post() compares the posted category with the stored one and removes all medafi_% rows for the post when it changed.

Renaming a field key must not orphan data in postsCheck uniqueness across all filter sections, then rename in postmeta with one UPDATE

change_meta_key() requires the medafi_ prefix, rejects a key already used in this or any other filter section and names the conflicting section. Then it renames the key inside html_items, rewrites the page_meta_data_filter arrays of affected posts, and runs UPDATE postmeta SET meta_key. It needs a nonce, edit_post and edit_others_posts because it touches all posts.

Results must render in the theme's own search templatepre_get_posts runs the results query and replaces global $wp_query, with a loop guard

parse_query() marks the main query as a search of the requested post type. pre_get_posts() renders [meta_data_filter_results] without a template; that view declares global $wp_query and assigns a new WP_Query built from the search. A request flag prevents re-entry, because the new query fires pre_get_posts again. The mdtf_prohibited_post_types filter keeps ACF and Contact Form 7 internal queries out.

WooCommerce 3.0 moved hidden products from meta to a taxonomyVersion switch: product_visibility NOT IN for 3.0+, _visibility meta before

Results, item counters and term counters all branch on WOOCOMMERCE_VERSION: from 3.0 they add a tax_query excluding exclude-from-catalog, earlier versions get a _visibility IN (catalog, visible) meta clause. An optional _stock_status clause hides out-of-stock products. With WPML active, every query also gets the current language.

Older drop-down options have no key, while posts store their titles as valuesfill_select_keys() uses the option title as key on read, matching stored post values

Drop-down options saved by older versions of the constructor have an empty key, while the post meta box stores the option title as the value. Instead of a migration, fill_select_keys() runs inside get_html_items() and on save, filling empty keys with the trimmed title and leaving explicit keys, including '0', untouched. Existing post values match without re-saving posts.

Security hardening

in the product

What it givesThe public search cannot be used to write unwanted data or reach admin actions, which protects the site and its content.

Problem
The plugin exposes public ajax endpoints, renders templates by name from options, and writes post meta from forms.
Solution

Server-side signatures for data echoed back by the browser, whitelists for template paths and meta keys, and capability and nonce checks on admin actions.

Stack
  • PHP
  • WordPress nonces
  • wp_hash
Tasks · 4
A widget inside a block must be redrawn by ajax recount from options carried by the pageSign widget class and options with wp_hash; accept only two known widget classes

A Legacy Widget inside a block has no sidebar where the recount could find it by ID, so its form carries the widget class and options. get_widget_redraw_data() signs them with wp_hash(); get_widget_from_redraw_data() verifies with hash_equals() and accepts only MetaDataFilter_Search and MetaDataFilter_TaxSearch before calling the_widget().

Results template path comes from shortcode and widget optionsStrip each path segment to [A-Za-z0-9_-]; sub folders allowed, '..' impossible

sanitize_template_path() normalizes slashes, filters every segment to a safe character set and drops empty ones, so a value cannot leave the templates folder while sub folders such as any/my_posts keep working.

The post edit form must not write meta keys other than filter fieldsAccept only medafi_ keys and require edit_post before saving values

save_post() removes every key without the medafi_ prefix from the posted array before storing it and checks the prefix again before each update_post_meta() call, so the form cannot set _edit_lock, _price or similar keys. Saving values requires current_user_can('edit_post').

Admin ajax actions must not be callable by any visitorCapability plus nonce on cache clearing, key rename and widget editing

Cache clearing requires manage_options and mdtf_ajax_nonce. Key rename requires its own nonce, edit_post and edit_others_posts. Widget field actions require edit_theme_options. Logged-out visitors reach only front-end search actions (term children, search encoding, recount, search panel, search button content). The other handlers registered for them check access themselves: the messenger subscribe actions require login and a nonce, the statistics helper requires manage_options.

Numbers

wordpress.org
  • Active installs 1,000+ wordpress.org counter.
  • Reviews 28 Average 4.5 out of 5.
  • Years supported 12 Since 22 April 2014, last update 13 October 2026.