Add a Low-Stock Filter to the WooCommerce Admin Area

WooCommerce low-stock filter tutorial

In this tutorial we’re going to make a custom “low stock” filter for the WooCommerce products admin table. We’ll also override the “In stock” text (in the Stock column) to say “low stock” where appropriate.

Low stock product admin table overview
Add a low-stock filter to the WooCommerce product admin table

There’s quite a lot going on here, so lets break this into a several small tasks:

  1. Create a core function that takes a WooCommerce product as its input parameter. If the product is variable (i.e. it has child products/variations), the function needs to return an array of meta data for all children that are low-stock.
  2. Hook the text for the “Stock” column and put our own “low stock” message in there for low-stock products.
  3. Add an input checkbox to the filter toolbar.
  4. Hook the core WordPress WP_Query so that when the admin page tries to return a list of products, we can restrict the result set to only include low-stock items (if our filter is applied).

Start at the beginning – scaffold the code

All the code for this project is in a single PHP file, and we will use a small CSS file to tidy up the filter toolbar. Make sure you’re using a custom child theme, then create a new file in there called wpt-low-stock-filter.php and paste the following into it:

<?php

/**
 * Headwall WP Tutorials WooCommerce Low Stock Filter (WPWCLSF)
 *
 * https://wp-tutorials.tech/add-functionality/add-low-stock-filter-to-the-woocommerce-admin-area/
 */

// Block direct access
defined('WPINC') || die();

/**
 * The name of our filter option. This will get passed to us in the
 * $_GET query parameters.
 */
const WPWCLSF_LOW_STOCK_FILTER_FIELD_NAME = 'lowstock';

/**
 * Set this to true if you want to include out-of-stock items with
 * the low-stock items.
 */
const WPWCLSF_INCLUDE_ZERO_STOCK_IN_FILTER = false; //true;

/**
 * Utility function tthat returns true if we're on the products admin table page,
 * edit.php?s&post_status=all&post_type=product
 */
function wpwclsf_is_on_the_product_admin_page() {
	global $wpwclsf_is_on_product_page;

	if (is_null($wpwclsf_is_on_product_page)) {
		// WordPress sets these so we know which page we're loading,
		// and what the core post type is.
		global $pagenow;
		global $typenow;

		$wpwclsf_is_on_product_page =
		is_admin() && // Are in the admin area.
		function_exists('WC') && // Is WooCommerce installed
		$pagenow == 'edit.php' && // Are we viewing the admin edit page
		$typenow == 'product'; // Is the main post-type "product"
	}

	return $wpwclsf_is_on_product_page;
}

/**
 * Checks the query parameters $_GET to see if our lowstock filter has been applied.
 */
function wpwclsf_is_low_stock_filter_applied() {
	$is_filter_applied = false;

	if (array_key_exists(WPWCLSF_LOW_STOCK_FILTER_FIELD_NAME, $_GET)) {
		$is_filter_applied = filter_var($_GET[WPWCLSF_LOW_STOCK_FILTER_FIELD_NAME], FILTER_VALIDATE_BOOLEAN);
	}

	return $is_filter_applied;
}

/**
 * Enqueue a small CSS file to tidy up our filter checkbox.
 */
function wpwclsf_admin_enqueue_scripts($current_page) {
	global $wpwclsf_is_on_product_page;

	if (wpwclsf_is_on_the_product_admin_page()) {
		$base_url = get_stylesheet_directory_uri();
		$theme_version = wp_get_theme()->get('Version');

		wp_enqueue_style(
			'wpwclsf-admin',
			$base_url . '/wpt-low-stock-filter.css',
			null, // No dependencies
			$theme_version
		);

		$wpwclsf_is_on_product_page = true;
	}
}
add_action('admin_enqueue_scripts', 'wpwclsf_admin_enqueue_scripts', 10, 1);

In the same folder (your child theme’s main folder), create a file called wpt-low-stock-filter.css and add the following:

/**
 * Headwall WP Tutorials WooCommerce Low Stock Filter (WPWCLSF)
 *
 * https://wp-tutorials.tech/add-functionality/add-low-stock-filter-to-the-woocommerce-admin-area/
 */

.low-stock-admin-filters {
	height: 32px;
	display: inline-flex;
	flex-direction: row;
	align-items: center;
}

mark.lowstock {
	color: darkorange;
	background-color: unset;
	font-weight: bold;
}

body .low-stock-admin-filters input[type='checkbox'] {
	width: 1rem;
	height: 1rem;
}

Next, open your child theme’s functions.php and add this snippet to it:

// Headwall WP Tutorials : Low Stock Filter
require_once dirname(__FILE__) . '/wpt-low-stock-filter.php';

Save all that and go to your site’s product admin table. You won’t see any changes yet because all we’ve done is put some “scaffold code” to get started:

  • wpwclsf_is_on_the_product_admin_page() returns true if we’re loading the product admin table page
  • wpwclsf_is_low_stock_filter_applied() checks $_GET to see if lowstock=on has been passed in the query parameters
  • wpwclsf_admin_enqueue_scripts() enqueues our small CSS file, but only if wpwclsf_is_on_the_product_admin_page() is true

Now we’ve got our supporting functions in there, we can create the core function of the project.

Core function : Create low stock product meta data

The core function should take a product as its input parameter, and return useful meta data if that product is low-stock.

The issue here is what to do about variable products.

WooCommerce lets us manage variable-product stock in the parent product, or in the child/variation products. So although our function will take a single product as its input, it might need to return low-stock meta data for multiple products. So we’re going to return an array of meta data here, even if we only need to test a simple product.

If the product is not low-stock, and none of its child are low-stock, the function will return an empty array.

Go into “wpt-low-stock-filter.php” and add the following to the end of the file:

/**
 * Our core function takes a WC_Product as its input and returns an array for
 * each product that is "low-stock". If $product is a variable product, then
 * all its child variations are checked. Otherwise, only $product is checked.
 *
 * @param WC_Product   $product  The product we're interested in
 *
 * @return array
 */
function wpwclsf_get_low_stock_metas($product) {
	// Make sure we've got a sensible value got the site-wide
	// low-stock threshold.
	global $wpwclsf_global_low_stock_threshold;
	if (is_null($wpwclsf_global_low_stock_threshold)) {
		$wpwclsf_global_low_stock_threshold = intval(get_option('woocommerce_notify_low_stock_amount', 0));
	}

	// Build an array of products that need to be checked.
	$products_to_check = [];
	if ($product->is_type('variable') && !$product->get_manage_stock()) {
		$variation_ids = $product->get_children();
		foreach ($variation_ids as $variation_id) {
			if (empty(($variation_product = wc_get_product($variation_id)))) {
				// ...
			} elseif (!$variation_product->get_manage_stock()) {
				// ...
			} else {
				$products_to_check[] = $variation_product;
			}
		}
	} elseif (!$product->get_manage_stock()) {
		// ...
	} else {
		$products_to_check[] = $product;
	}

	// Check our array of products for stock levels.
	$low_stock_metas = [];
	foreach ($products_to_check as $product_to_check) {
		$low_stock_threshold = intval($product_to_check->get_low_stock_amount());
		if ($low_stock_threshold <= 0) {
			$low_stock_threshold = $wpwclsf_global_low_stock_threshold;
		}

		$current_stock_level = intval($product_to_check->get_stock_quantity());

		if ($current_stock_level > $low_stock_threshold) {
			// Stock is above the low-stock threshold
		} elseif (!WPWCLSF_INCLUDE_ZERO_STOCK_IN_FILTER && $current_stock_level <= 0) {
			// Out of stock
		} else {
			$low_stock_metas[] = [
				'id' => $product_to_check->get_id(),
				'sku' => $product_to_check->get_sku(),
				'name' => $product_to_check->get_name(),
				'threshold' => $low_stock_threshold,
				'stock-level' => $current_stock_level,
				'type' => $product_to_check->get_type(),
			];
		}
	}

	return $low_stock_metas;
}

Look through wpwclsf_get_low_stock_metas() and you’ll see it consist of three chunks. The first (small) chunk makes sure that $wpwclsf_global_low_stock_threshold has a sensible value in it.

The second chunk builds an array of products that need to be checked. If $product is not variable, then $products_to_check will be an array with just the one product in it. But if $product is variable (and it has child variations) and stock management is not enabled at the variable product level, $products_to_check will contain all the child products that have stock management enabled.

The third chunk loops over $products_to_check and gets the current stock level. Any products in the low-stock range are added to the $low_stock_metas array, which is returned at the end of the function.

Display the low stock info

WooCommerce has a filter called “woocommerce_admin_stock_html” we can use to override the “In stock” text in the “Stock” columns. Paste the following to the end of “wpt-low-stock-filter.php” to hook the filter:

/**
 * Potentially override the "In stock", "Out of stock" text in the Stock column
 * in the products admin table.
 */
function wpwclsf_override_admin_stock_html($html, $product) {
	if (!wpwclsf_is_on_the_product_admin_page()) {
		// ...
	} elseif (empty(($low_stock_metas = wpwclsf_get_low_stock_metas($product)))) {
		// The product is not low on stock
	} else {
		$html = sprintf('<mark class="lowstock">%s</mark>', esc_html__('LOW STOCK', 'wp-tutorials'));
		if (WPWCLSF_INCLUDE_ZERO_STOCK_IN_FILTER) {
			$html = sprintf('<mark class="lowstock">%s</mark>', esc_html__('LOW/NO STOCK', 'wp-tutorials'));
		}

		foreach ($low_stock_metas as $low_stock_meta) {
			$html .= sprintf('<br /><strong>%d</strong>&nbsp;(%d)', $low_stock_meta['stock-level'], $low_stock_meta['threshold']);

			// For children/variations, we should show the SKU too.
			if ($low_stock_meta['type'] == 'variation') {
				$html .= sprintf('&nbsp;<em>%s</em>', esc_html($low_stock_meta['sku']));
			}
		}
	}

	return $html;
}
add_filter('woocommerce_admin_stock_html', 'wpwclsf_override_admin_stock_html', 10, 2);

Save that and reload the product admin table. You should see “LOW STOCK” text come through now – assuming you’ve got some low-stock products in there, of course.

On my dev site I’ve got the low stock threshold set at 15, so the Denim Jacket (simple) and the Hoodie (variable) show as being “LOW STOCK”. The Hoodie shows which variations are low-stock too 😎

Filter for low-stock products

Add the filter option checkbox

Before we can filter the data, we need a way of enabling/disabling the low-stock filter. So lets inject a checkbox & label into the toolbar just above the table. Paste the following to the end of “wpt-low-stock-filter.php” and read through the function:

/**
 * Add a checkbox to the admin products table filters.
 */
function wpwclsf_render_filter_option() {
	if (wpwclsf_is_on_the_product_admin_page()) {
		$props = '';
		if (wpwclsf_is_low_stock_filter_applied()) {
			$props .= ' checked';
		}

		echo '<span class="low-stock-admin-filters">';

		printf(
			'<input id="%s" name="%s" type="checkbox" %s />',
			esc_attr(WPWCLSF_LOW_STOCK_FILTER_FIELD_NAME), // id
			esc_attr(WPWCLSF_LOW_STOCK_FILTER_FIELD_NAME), // name
			$props
		);

		printf(
			'<label for="%s">%s</label>',
			esc_attr(WPWCLSF_LOW_STOCK_FILTER_FIELD_NAME), // The id of our checkbox input
			esc_html__('Only low-stock', 'wp-tutorials')
		);

		echo '</span>'; // .low-stock-admin-filters
	}
}
add_action('restrict_manage_posts', 'wpwclsf_render_filter_option', 10);

The restrict_manage_posts action is triggered by WordPress when any post admin table is being rendered (Posts, Products, Pages, etc). We only want to add our filter option to the Products table, so our handler makes a call to wpwclsf_is_on_the_product_admin_page() before we output any HTML. The HTML itself is easy to follow – it’s just a span with an input and a checkbox inside it. The field name for the checkbox is “lowstock”, specified by WPWCLSF_LOW_STOCK_FILTER_FIELD_NAME.

Save the changes, reload the product admin table and you should see the new filter option.

We can run a quick test here to see what happens when we try to apply the filter.

Click the checkbox then press the “Filter” button. The page will reload and you’ll see “lowstock=on” in the URL.

Low stock filter query parameters
Low-stock filter and query parameters

Filter the product data

Now we’ve got the filter checkbox, and we can see the filter option being passed into the URL, we can filter the product data.

The standard way to adjust a WP_Query before it runs is to use the pre_get_posts filter. Add the following snippet to “wpt-low-stock-filter.php” to hook it and modify the query:

/**
 * If the main query (for the page) is about to run, and we're on the product
 * admin table page, run a quick sub-query
 */
function wpwclsf_pre_get_posts($query) {
	if (!$query->is_main_query()) {
		// This is not the main query for the page
	} elseif (!wpwclsf_is_on_the_product_admin_page()) {
		// We're not on the products admin table
	} elseif (!wpwclsf_is_low_stock_filter_applied()) {
		// Our low-stock filter is not applied
	} else {
		$sub_query = [
			'post_type' => 'product',
			'post_status' => 'publish',
			'fields' => 'ids', // We only need the product post IDs
			'numberposts' => -1,
		];

		$low_stock_ids = [];
		$all_product_ids = get_posts($sub_query);
		foreach ($all_product_ids as $product_id) {
			if (empty(($product = wc_get_product($product_id)))) {
				// We should never end up in here
				error_log(__FUNCTION__ . ' Filtered post is not a product');
			} elseif (empty(($low_stock_metas = wpwclsf_get_low_stock_metas($product)))) {
				// This product is not low-stock
			} else {
				$low_stock_ids[] = $product_id;
			}
		}

		$query->set('post__in', $low_stock_ids);
	}
}
add_action('pre_get_posts', 'wpwclsf_pre_get_posts', 10, 1);

We start by checking that we’re running the main query for the product admin table, and the low-stock filter has been applied. Then we make a small sub-query to grab all the products from the database using get_posts(). That might seem heavy-handed, but we use 'fields' => 'ids' to keep the internal database query fairly light. Then we loop over all these post/product ids to make a new array that only contains low-stock product ids. Finally, we restrict the main query using 'post__in' => $low_stock_ids.

We don’t adjust anything else in the main $query object so pagination, category filters, etc will still work as normal.

Wrapping up

That’s the core of the low-stock filter code.

There’s plenty of scope for optimisation here, if you want to extend it:

  • Looping over the child variations inside wpwclsf_get_low_stock_metas() is potentially costly.
  • Running a sub-query in wpwclsf_pre_get_posts() could slow things down

This code should be fine on stores with a few hundred products. But if you’ve got thousands of products then you should look at caching the low-stock meta, or even saving the low-stock meta into a post_meta field against the product. That would be a different approach, but would make the table query a lot faster.

You could also move the check box into the “stock status” drop-down for better consistency in the UI.

But as a mini WooCommerce project, this was a bit of an interesting one… Happy filtering 😎 👍

Like This Tutorial?

Let us know

WordPress plugins for developers

Leave a comment