Add WooCommerce product sorting and price filtering without a plugin

WooCommerce includes a basic product sorting dropdown (by popularity, rating, price, newness) on archive pages, but it does not include attribute or category filters out of the box — that requires either the WooCommerce Blocks filter blocks, the WooCommerce Attribute Filter widget (which uses URL query strings), or a plugin. However, for developers who want full control over the HTML, style, and behaviour of sorting and filtering, doing it with custom PHP and JavaScript gives a cleaner result with no plugin overhead. WooCommerce exposes the active sorting order via the orderby URL parameter, which maps to WP_Query arguments through the woocommerce_get_catalog_ordering_args filter. Custom attribute filtering is most cleanly done by appending taxonomy query vars to the URL and using the pre_get_posts hook to merge them into the main shop query. AJAX-based filtering (no page reload) requires a custom AJAX handler that returns rendered product HTML — the same approach as covered in the Fetch API guide. The snippet below adds a custom sorting select and a price-range filter that work with standard WooCommerce archive pages using URL parameters, compatible with WooCommerce’s built-in price display and cart functions.

Problem: The default WooCommerce sorting options are insufficient and you need custom product sorting and attribute-based filtering on shop archive pages without installing a plugin.

Solution: Add the following code to your functions.php file:

// Add custom sort option: alphabetical A-Z
add_filter( 'woocommerce_catalog_orderby', 'helloadmin_add_custom_orderby' );
function helloadmin_add_custom_orderby( array $options ): array {
    $options['name_asc']  = __( 'Name: A to Z', 'woocommerce' );
    $options['name_desc'] = __( 'Name: Z to A', 'woocommerce' );
    return $options;
}

add_filter( 'woocommerce_get_catalog_ordering_args', 'helloadmin_custom_ordering_args' );
function helloadmin_custom_ordering_args( array $args ): array {
    $orderby = isset( $_GET['orderby'] ) ? wc_clean( wp_unslash( $_GET['orderby'] ) ) : '';

    if ( 'name_asc' === $orderby ) {
        $args['orderby'] = 'title';
        $args['order']   = 'ASC';
    } elseif ( 'name_desc' === $orderby ) {
        $args['orderby'] = 'title';
        $args['order']   = 'DESC';
    }

    return $args;
}

// Filter products by price range via URL parameters (?min_price=10&max_price=50)
add_action( 'pre_get_posts', 'helloadmin_price_range_filter' );
function helloadmin_price_range_filter( WP_Query $query ): void {
    if ( ! $query->is_main_query() || ! is_shop() && ! is_product_category() ) {
        return;
    }

    $min = isset( $_GET['min_price'] ) ? floatval( $_GET['min_price'] ) : null;
    $max = isset( $_GET['max_price'] ) ? floatval( $_GET['max_price'] ) : null;

    if ( null === $min && null === $max ) {
        return;
    }

    $meta_query   = $query->get( 'meta_query' ) ?: [];
    $price_filter = [ 'key' => '_price', 'type' => 'NUMERIC' ];

    if ( null !== $min && null !== $max ) {
        $price_filter['value']   = [ $min, $max ];
        $price_filter['compare'] = 'BETWEEN';
    } elseif ( null !== $min ) {
        $price_filter['value']   = $min;
        $price_filter['compare'] = '>=';
    } else {
        $price_filter['value']   = $max;
        $price_filter['compare'] = '<=';
    }

    $meta_query[] = $price_filter;
    $query->set( 'meta_query', $meta_query );
}

// Output a simple price-range filter form (call in your archive template)
function helloadmin_price_filter_form(): void {
    $min = isset( $_GET['min_price'] ) ? floatval( $_GET['min_price'] ) : '';
    $max = isset( $_GET['max_price'] ) ? floatval( $_GET['max_price'] ) : '';
    ?>
    <form method="get" class="helloadmin-price-filter">
        <label>Min price: <input type="number" name="min_price" value="<?php echo esc_attr( $min ); ?>" min="0" step="1"></label>
        <label>Max price: <input type="number" name="max_price" value="<?php echo esc_attr( $max ); ?>" min="0" step="1"></label>
        <?php
        // Preserve existing query vars (orderby, paged, etc.)
        foreach ( $_GET as $key => $val ) {
            if ( ! in_array( $key, [ 'min_price', 'max_price' ], true ) ) {
                printf( '<input type="hidden" name="%s" value="%s">', esc_attr( $key ), esc_attr( $val ) );
            }
        }
        ?>
        <button type="submit"><?php esc_html_e( 'Filter', 'woocommerce' ); ?></button>
    </form>
    <?php
}

NOTE: The price filter uses _price postmeta which stores the active price (sale price when on sale, regular price otherwise). Filtering by _regular_price instead will ignore sale discounts. For stores with many products (500+), meta-query-based price filtering can be slow — WooCommerce’s own lookup tables (wc_product_meta_lookup) exist for this reason. For high-traffic shops, use woocommerce_product_query_meta_query to hook into the optimised lookup table instead of pre_get_posts.