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.