Creating a User-Friendly URL Filter

This tutorial shows how to create a user-friendly URL filter for any WordPress post type. Follow our detailed steps to enhance filtering with structured and clean URLs.

We are creating a filter by taxonomies and meta fields for any post type. This code and approach are universal for all post types and taxonomies; all you need to do is substitute your data.

Example of URL formation:
/{post_type_name}/filter/{taxonomy_name}-in-{term_slug}-or-{second_term_slug}-and-{meta_name}-in-{meta_value}

Separators:

  • -and- — first-level separator
  • -in- — second-level separator
  • -or- — third-level separator

First, we need to override permalink rules. For this, we use add_action(‘init’, ‘rewrite_rules’), where bots is my custom post type.

/**
 * Rewrite rules filter.
 *
 * @return void
 */
function rewrite_rules(): void {
add_rewrite_rule(
'bots/filter/([-_a-zA-Z0-9+%.:]+)/page/(\d+)/?$',
'index.php?post_type=bots&filter=$matches[1]&paged=$matches[2]',
'top'
); // rule for pagination support

add_rewrite_rule(
    'bots/filter/([-_a-zA-Z0-9+%.:]+)/?$',
    'index.php?post_type=bots&filter=$matches[1]',
    'top'
); // main rule for overriding

}
add_action('init', 'rewrite_rules');

Now, we need to add our variable to query_vars. This is necessary to store our data in a global variable and make it accessible.

/**
 * Add Query vars filter
 *
 * @param array $vars Vars Query.
 *
 * @return array
 */
function add_filter_vars( array $vars ): array {
    $vars[] = 'filter';

    return $vars;
}

add_filter( 'query_vars', 'add_filter_vars'  );

Next, we create a filter form. Here is the code I used in one of my projects.

<form action="<?php echo esc_url(admin_url('admin-post.php')); ?>" method="post" class="filter-form">
    <!-- Your HTML form code here -->
    <?php wp_nonce_field(TBL_FILTER_ACTION_NAME, 'filter_nonce'); ?>
    <input type="hidden" name="action" value="<?php echo esc_attr(TBL_FILTER_ACTION_NAME); ?>">
    <input type="hidden" id="sort_by" name="tbl_filter[sort_by]" value="">
    <input type="hidden" id="price_sort" name="tbl_filter[price_sort]" value="">
    <input class="button" type="submit" value="<?php esc_html_e('Filtered', 'telegram-bots-listing'); ?>">
</form>

To process form data, I use admin-post, which allows for the processing of form data and the generation of a URL based on the data, following the same principle as a regular AJAX request handler.

/**
 * Filter bots handler.
 *
 * @return void
 */
public function filter_bot_handler(): void {
    $filter_nonce = !empty($_POST['filter_nonce']) ? filter_var(wp_unslash($_POST['filter_nonce']), FILTER_SANITIZE_SPECIAL_CHARS) : null; // retrieve and clean data with the nonce code
    if (!wp_verify_nonce($filter_nonce, Main::TBL_FILTER_ACTION_NAME)) {
        wp_safe_redirect(filter_input(INPUT_POST, '_wp_http_referer', FILTER_SANITIZE_STRING));
        die();
    } // check the nonce, and if it's not valid, redirect the user back

    $filter_args = [
        'category'   => ['filter' => FILTER_SANITIZE_SPECIAL_CHARS, 'flags' => FILTER_FORCE_ARRAY],
        'language'   => ['filter' => FILTER_SANITIZE_SPECIAL_CHARS, 'flags' => FILTER_FORCE_ARRAY],
        'price'      => FILTER_SANITIZE_SPECIAL_CHARS,
        'sort_by'    => FILTER_SANITIZE_SPECIAL_CHARS,
        'price_sort' => FILTER_SANITIZE_SPECIAL_CHARS,
    ]; // array for cleaning and validating form data

    // phpcs:disable
    $filter_request = !empty($_POST['tbl_filter']) ? filter_var_array(wp_unslash($_POST['tbl_filter']), $filter_args) : [];
    $rating = !empty($_POST['review_rating']) ? filter_var(wp_unslash($_POST['review_rating']), FILTER_SANITIZE_NUMBER_INT) : null;
    // phpcs:enable

    if (!empty($rating)) {
        $filter_request['rating'] = $rating;
    }

    $param_url = generate_filter_url($filter_request); // pass the array to a function that will generate our URL
    wp_safe_redirect($param_url . '/', 301); // redirect the user to our URL
    die();
}

add_action('admin_post_nopriv_' . YOUR_ACTION_NAME, 'filter_bot_handler');
add_action('admin_post_' . YOUR_ACTION_NAME, 'filter_bot_handler');

The array of special character replacements:

const TBL_CODE_MATCH = [
    '"', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '[', ']', ';', "'", '', '.', '/', '', '~', '`', '='
];

Function to generate the filter URL:

/**
 * Generate filter URL.
 *
 * @param array $params Params.
 * @return string
 */
function generate_filter_url(array $params): string {
    $params_array = [];
    $redirect = get_bloginfo('url') . '/bots/filter/';
    foreach ($params as $key => $item) {
        $params_array[$key] = [];
        if (is_array($item)) {
            foreach ($item as $value) {
                $params_array[$key][] = rawurlencode(str_replace(self::TBL_CODE_MATCH, '-', $value)); // clean special characters in the data and replace them with '-'
            }
        } else {
            $params_array[$key][] = rawurlencode(str_replace(self::TBL_CODE_MATCH, '-', $item)); // clean special characters in the data and replace them with '-'
        }
    }
    $i = 0;
    foreach ($params_array as $key => $param) {
        if (0 === $i && !empty($param[0])) {
            $redirect .= $key . '-in-' . implode('-or-', (array) $param);
            $i++;
            continue;
        }
        foreach ($param as $index => $item) {
            if (!empty($item)) {
                $addon = (0 === $index) ? '-and-' . $key . '-in-' : '-or-';
                $redirect .= $addon . $item;
            }
        }
    }
    return $redirect;
}

The last step is to parse this URL and modify the main query through a filter using this action: add_action(‘pre_get_posts’, ‘add_query_filter’).

/**
 * Filter query.
 *
 * @param WP_Query $query WP Query.
 * @return WP_Query
 */
function add_query_filter(WP_Query $query) {
    if (
        !is_admin() && $query->is_main_query() &&
        is_post_type_archive('bots') &&
        !empty($query->query_vars['filter'])
    ) {
        // check for main_query and if data is present in query_vars['filter']
        $args = [];
        $filter_params = parse_url_query($query->query_vars['filter']); // parse our URL
        if (empty($filter_params)) {
            return $query;
        }
        foreach ($filter_params as $key => $value) {
            switch ($key) {
                case 'category':
                    $args['tax_query'][] = [
                        'taxonomy' => 'bot-category',
                        'field'    => 'slug',
                        'terms'    => $value,
                        'operator' => 'IN'
                    ];
                    break;
                case 'language':
                    $args['tax_query']['relation'] = 'AND';
                    $args['tax_query'][] = [
                        'taxonomy' => 'bot-languages',
                        'field'    => 'slug',
                        'terms'    => $value,
                        'operator' => 'IN'
                    ];
                    break;
                // additional cases...
            }
        }
        foreach ($args as $key => $arg) {
            if (!empty($arg)) {
                $query->set($key, $arg);
            }
        }
        return $query;
    }
    return $query;
}

add_action('pre_get_posts', 'add_query_filter');

Function to parse the string URL:

/**
 * Parse string URL
 *
 * @param string $url_query URL Query.
 * @return array|false
 */
function parse_url_query(string $url_query) {
    $param_string = $url_query;
    $query_arg = [];
    if (!empty($param_string)) {
        $params = explode('-and-', $param_string);
        foreach ($params as $param) {
            $items = explode('-in-', urldecode($param));
            $param_name = $items[0];
            unset($items[0]);
            if (preg_match('-or-', $items[1])) {
                $query_arg[$param_name] = explode('-or-', $items[1]);
            } else {
                $query_arg[$param_name] = $items;
            }
        }
        return array_filter($query_arg);
    } else {
        return false;
    }
}

When we add this code, the filter should work in the archive_{post_type} template, and our filtered data will be available in the standard output loop.

Related posts

Insights and Tips from My Journey

WordPress Security Best Practices: Safeguarding Your Website

  • 13.10.2024
  • 51

Carbon Fields VS ACF PRO Gutenberg blocks

  • 07.10.2024
  • 174

Step-by-Step Guide to Automatically Create Patterns in WordPress

  • 03.09.2024
  • 112
All Posts

Ready to Take Your Project
to the Next Level?

Let’s bring your vision to life with expert development and custom solutions.
Contact us now and get started on transforming your ideas into reality!