As a WooCommerce store owner, you know how frustrating it can be to manage complex product filtering for your customers. With multiple attributes and categories, filtering products efficiently becomes a challenge. You’ve likely had to rely on third-party plugins or spend hours customizing default filters, only to have them break with new updates.
You’ll build a custom WooCommerce product filter that’s both dynamic and user-friendly. By the end of this tutorial, you’ll have created a seamless shopping experience for your customers using Tailwind CSS utilities and Livewire’s server-side rendering. Specifically, you’ll be able to implement a filter model and database migration and integrate the filter with WooCommerce products and queries, allowing for effortless filtering of your products by attributes like color, size, and category.
Prerequisites: Installing WooCommerce, Tailwind CSS, and Livewire
To get started with building a custom WooCommerce product filter using Tailwind CSS and Livewire, you’ll first need to install the required dependencies. WooCommerce is a WordPress plugin, while Livewire is a Laravel package, so the filter lives in a Laravel storefront application that reads your store’s catalog through the WooCommerce REST API.
Step 1: Install WooCommerce
If you haven’t already installed WooCommerce on your WordPress site, do so by navigating to your site’s admin dashboard (http://your-site.com/wp-admin), then go to Plugins > Add New and search for “WooCommerce”. Click “Install Now” to install the plugin.
# From your terminal, using WP-CLI (optional)
wp plugin install woocommerce --activate
Next, create REST API credentials so your Laravel application can read the catalog. In the WordPress admin, go to WooCommerce > Settings > Advanced > REST API, click “Add key”, give it Read permissions, and copy the generated consumer key and secret into your Laravel project’s .env file:
WOOCOMMERCE_URL=https://your-site.com
WOOCOMMERCE_KEY=ck_your_consumer_key
WOOCOMMERCE_SECRET=cs_your_consumer_secret
Then expose these values through config/services.php:
// config/services.php
'woocommerce' => [
'url' => env('WOOCOMMERCE_URL'),
'key' => env('WOOCOMMERCE_KEY'),
'secret' => env('WOOCOMMERCE_SECRET'),
],
Step 2: Install Tailwind CSS
For this tutorial, we’ll be building our product filter UI with Tailwind CSS. Tailwind CSS v4 is configured through its Vite plugin, so you don’t need a tailwind.config.js file. First, install Tailwind CSS and its Vite plugin in the root of your Laravel project:
npm install -D tailwindcss @tailwindcss/vite
Next, register the plugin in vite.config.js, then add @import "tailwindcss"; to the top of resources/css/app.css. Tailwind automatically scans your Blade templates for class names:
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
tailwindcss(),
],
});
Step 3: Install Livewire
Livewire will handle our product filter’s server-side rendering. Run the following command to install it (Livewire registers itself through Laravel’s package auto-discovery, so no further setup is needed):
composer require livewire/livewire
Make sure you’ve completed these steps before moving on to setting up your custom product filter Blade component in the next section.
Setting Up the Custom Product Filter Blade Component
In this step, we’ll create a custom Blade component for our product filter. This will allow us to reuse the filter in different parts of our WooCommerce store without duplicating code.
First, open your terminal and navigate to the resources/views/components directory within your Laravel project (creating it first if it doesn’t exist yet):
mkdir -p resources/views/components
cd resources/views/components
Next, create a new file called product-filter.blade.php using your preferred text editor or IDE:
touch product-filter.blade.php
Now, add the following code to product-filter.blade.php:
<!-- resources/views/components/product-filter.blade.php -->
<div class="flex flex-wrap -mx-4">
@foreach($filters as $filter)
<div class="w-full md:w-1/2 xl:w-1/3 px-4 mb-8">
<h5>{{ $filter->name }}</h5>
<ul>
@foreach($terms[$filter->wc_attribute_id] ?? [] as $term)
<li>
<a href="#" wire:click.prevent="applyFilter('{{ $filter->attribute_slug }}', {{ $term['id'] }})">
{{ $term['name'] }}
</a>
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
This Blade component will display a list of filters and their corresponding options (the attribute terms returned by WooCommerce). Note the use of wire:click to call a Livewire action when an option is clicked.
We’ll complete this component in later sections by implementing the necessary logic for filtering WooCommerce products.
Creating the Product Filter Model and Database Migration
Now that we have our Blade component set up, let’s create a model for our product filter data. This will allow us to store and retrieve filter settings from the database. Each row maps a WooCommerce product attribute (such as pa_color) to a filter shown in the UI.
// File: app/Models/ProductFilter.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ProductFilter extends Model
{
use HasFactory;
protected $fillable = [
'name',
'attribute_slug',
'wc_attribute_id',
];
}
Next, we’ll create a migration to set up the database table for our product filter data. We’ll use Laravel’s built-in Schema facade to define the table structure.
// File: database/migrations/2023_02_20_000000_create_product_filters_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('product_filters', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('attribute_slug');
$table->unsignedBigInteger('wc_attribute_id');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('product_filters');
}
};
Run the migration to create the product_filters table in your database:
php artisan migrate
With our product filter model and database table set up, we’re now ready to implement server-side rendering for filtering using Livewire.
Implementing Livewire’s Server-Side Rendering for Filtering
To enable server-side rendering for our product filter, we’ll use Livewire’s built-in support for it. This will allow us to render the filtered products on the server instead of relying solely on JavaScript.
Livewire components are rendered on the server by default: the first render happens during the normal page request, and every later interaction sends a small request back to the server, which re-renders the component and returns the updated HTML. First, let’s create a small service class that talks to the WooCommerce REST API using Laravel’s HTTP client:
// app/Services/WooCommerce.php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class WooCommerce
{
protected function client(): PendingRequest
{
return Http::baseUrl(rtrim(config('services.woocommerce.url'), '/') . '/wp-json/wc/v3')
->withBasicAuth(config('services.woocommerce.key'), config('services.woocommerce.secret'))
->acceptJson();
}
public function products(array $query = []): array
{
$response = $this->client()->get('products', $query)->throw();
return [
'items' => $response->json(),
'total_pages' => (int) $response->header('X-WP-TotalPages'),
];
}
public function categories(): array
{
return $this->client()->get('products/categories', ['per_page' => 100])->throw()->json();
}
public function attributeTerms(int $attributeId): array
{
return $this->client()->get("products/attributes/{$attributeId}/terms", ['per_page' => 100])->throw()->json();
}
}
Next, generate the Livewire component with php artisan make:livewire ProductFilter --class and give its Blade template a search box bound with wire:model.live, so every change is sent to the server and the product list is re-rendered there:
{{-- resources/views/livewire/product-filter.blade.php --}}
<div>
<input type="search" wire:model.live.debounce.300ms="filterQuery" placeholder="Search products">
@foreach($products as $product)
{{ $product['name'] }}
@endforeach
</div>
Now, let’s fill in the ProductFilter component class so it fetches the matching products from WooCommerce every time it renders:
// app/Livewire/ProductFilter.php
namespace App\Livewire;
use App\Services\WooCommerce;
use Livewire\Component;
class ProductFilter extends Component
{
public $filterQuery = '';
public function render()
{
$products = app(WooCommerce::class)->products([
'search' => $this->filterQuery,
'per_page' => 12,
])['items'];
return view('livewire.product-filter', compact('products'));
}
}
With these updates, we’ve enabled server-side rendering for our product filter. This will improve performance and make our application more scalable. We’ll continue to build on this foundation in the next section by integrating the filter with WooCommerce products and query.
Building the Filter UI with Tailwind CSS Utilities
Now that we have Livewire handling the server-side rendering for our product filter, it’s time to create a user-friendly interface for our filter options using Tailwind CSS utilities.
First, let’s create a new Blade component for our filter UI. In the resources/views/components directory, create a file named filter-ui.blade.php. Add the following code:
<!-- resources/views/components/filter-ui.blade.php -->
<div class="flex flex-wrap -mx-4 mt-8">
<div class="w-full md:w-1/2 lg:w-1/3 xl:w-1/4 px-4 mb-6 md:mb-0">
<h2 class="text-lg font-bold text-gray-900">Price</h2>
<input type="number" id="price-from" wire:model.live.debounce.500ms="filter.priceFrom"
class="block p-2 w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
<div class="w-full md:w-1/2 lg:w-1/3 xl:w-1/4 px-4 mb-6 md:mb-0">
<h2 class="text-lg font-bold text-gray-900">Category</h2>
<select id="category" wire:model.live="filter.category"
class="block p-2 w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<option value="">Select a category</option>
@foreach($categories as $category)
<option value="{{ $category['id'] }}">{{ $category['name'] }}</option>
@endforeach
</select>
</div>
<!-- Add more filter options here -->
</div>
This code sets up a basic layout for our filter UI using Tailwind CSS classes. We’ll add more filter options and styles as needed.
To display this filter UI in our product list page, we include the filter-ui partial inside the ProductFilter component’s view with @include('components.filter-ui') (so its wire:model bindings reach the component’s properties), and render the Livewire component in our page template, like so:
<!-- resources/views/products.blade.php -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
<div class="flex flex-col items-center">
<livewire:product-filter />
</div>
@livewireScripts
With this setup, we now have a functional product filter UI that updates dynamically as users interact with the filter options.
Integrating the Filter with WooCommerce Products and Query
In this step, we’ll combine our product filter with the actual products in our WooCommerce store. We need to modify our ProductFilter Livewire component to fetch and display the filtered products.
// app/Livewire/ProductFilter.php
namespace App\Livewire;
use App\Models\ProductFilter as FilterDefinition;
use App\Services\WooCommerce;
use Livewire\Component;
class ProductFilter extends Component
{
public $filterQuery = '';
public $filter = ['priceFrom' => null, 'category' => ''];
public $attribute = null;
public $attributeTerm = null;
public $page = 1;
public function applyFilter($attribute, $termId)
{
$this->attribute = $attribute;
$this->attributeTerm = $termId;
$this->page = 1;
}
public function updated()
{
// Any change to a bound filter field starts again from the first page
$this->page = 1;
}
public function nextPage()
{
$this->page++;
}
public function previousPage()
{
$this->page = max(1, $this->page - 1);
}
public function render()
{
$woocommerce = app(WooCommerce::class);
// Build the WooCommerce REST API query from the current filter values
$query = array_filter([
'search' => $this->filterQuery,
'category' => $this->filter['category'],
'min_price' => $this->filter['priceFrom'],
'attribute' => $this->attribute,
'attribute_term' => $this->attributeTerm,
'orderby' => 'rating',
'order' => 'desc',
'per_page' => 12,
'page' => $this->page,
], fn ($value) => $value !== null && $value !== '');
$result = $woocommerce->products($query);
$filters = FilterDefinition::all();
$terms = $filters->mapWithKeys(fn ($filter) => [
$filter->wc_attribute_id => $woocommerce->attributeTerms($filter->wc_attribute_id),
])->all();
return view('livewire.product-filter', [
'filters' => $filters,
'terms' => $terms,
'categories' => $woocommerce->categories(),
'products' => $result['items'],
'totalPages' => $result['total_pages'],
]);
}
}
This code fetches the filtered products in the render() method, which turns the current filter values into query parameters for the WooCommerce /wp-json/wc/v3/products endpoint (search, category, min_price, attribute, and attribute_term). WooCommerce returns the results paginated (12 per page) and sorted by average rating in descending order, and the total number of pages comes from the X-WP-TotalPages response header. Because the Livewire component is named ProductFilter, the Eloquent model is imported under the alias FilterDefinition to avoid a class name conflict.
Make sure to update your product filter component template (resources/views/livewire/product-filter.blade.php) to keep the search box, include the filter partials, and display the fetched products:
{{-- resources/views/livewire/product-filter.blade.php --}}
<div class="flex flex-col">
<input type="search" wire:model.live.debounce.300ms="filterQuery" placeholder="Search products">
@include('components.filter-ui')
@include('components.product-filter')
@foreach($products as $product)
<div>
{{ $product['name'] }} ({{ $product['price'] }})
</div>
@endforeach
<div class="flex gap-4 mt-6">
@if ($page > 1)
<button type="button" wire:click="previousPage">Previous</button>
@endif
@if ($page < $totalPages)
<button type="button" wire:click="nextPage">Next</button>
@endif
</div>
</div>
This will render the filtered products below our product filter component, making it easy for users to browse and discover relevant items.
Adding Dynamic Filter Options with Ajax Requests
To enhance user experience and improve filter usability, let’s add dynamic filter options using Ajax requests. We’ll create a new Livewire component responsible for fetching and updating available filters.
First, run the following command in your terminal:
php artisan make:livewire ProductFilterAjax --class
This will generate two files: app/Livewire/ProductFilterAjax.php and resources/views/livewire/product-filter-ajax.blade.php.
In the generated component file (ProductFilterAjax.php), add a new method to fetch available filters. Livewire sends the call to the server as an Ajax request for you, so the method is plain PHP:
namespace App\Livewire;
use App\Models\ProductFilter;
use Livewire\Component;
class ProductFilterAjax extends Component
{
public $availableFilters = [];
public function render()
{
return view('livewire.product-filter-ajax');
}
public function fetchAvailableFilters()
{
$this->availableFilters = ProductFilter::all(['id', 'name', 'attribute_slug'])->toArray();
}
}
Then use the wire:init directive in the component’s view to call this method as soon as the component appears on the page:
{{-- resources/views/livewire/product-filter-ajax.blade.php --}}
<div wire:init="fetchAvailableFilters">
@foreach($availableFilters as $filter)
<span class="inline-block px-3 py-1 mr-2 text-sm bg-gray-100 rounded-full">{{ $filter['name'] }}</span>
@endforeach
</div>
If other parts of your storefront also need the filter list as JSON (for example, plain JavaScript widgets), update the routes/web.php route file to include a new route for handling Ajax requests:
use App\Http\Controllers\ProductFilterController;
use Illuminate\Support\Facades\Route;
Route::get('/product-filter/ajax/fetch', [ProductFilterController::class, 'fetchAvailableFilters'])
->name('product.filter.ajax.fetch');
Finally, add the necessary logic in your ProductFilterController to fetch and return available filters. For example:
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\ProductFilter;
class ProductFilterController extends Controller
{
public function fetchAvailableFilters(Request $request)
{
$filters = ProductFilter::all()->toArray();
return response()->json($filters);
}
}
This will allow the product filter to dynamically load available options, improving user experience.
Optimizing Performance: Caching and Minifying the Product Filter
After implementing our custom product filter with Livewire and Tailwind CSS, it’s essential to optimize its performance to ensure a seamless user experience. In this final section, we’ll cover caching and minification techniques to improve page load times.
Caching
To cache the product filter results, we can utilize Laravel’s built-in caching system. First, let’s wrap the WooCommerce API calls in our service class with Cache::remember(), building the cache key from the query parameters so each filter combination is cached separately:
// app/Services/WooCommerce.php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class WooCommerce
{
protected function client(): PendingRequest
{
return Http::baseUrl(rtrim(config('services.woocommerce.url'), '/') . '/wp-json/wc/v3')
->withBasicAuth(config('services.woocommerce.key'), config('services.woocommerce.secret'))
->acceptJson();
}
public function products(array $query = []): array
{
ksort($query);
return Cache::remember('woocommerce:products:' . md5(http_build_query($query)), 3600, function () use ($query) {
$response = $this->client()->get('products', $query)->throw();
return [
'items' => $response->json(),
'total_pages' => (int) $response->header('X-WP-TotalPages'),
];
});
}
public function categories(): array
{
return Cache::remember('woocommerce:categories', 3600, fn () => $this->client()
->get('products/categories', ['per_page' => 100])->throw()->json());
}
public function attributeTerms(int $attributeId): array
{
return Cache::remember("woocommerce:attribute-terms:{$attributeId}", 3600, fn () => $this->client()
->get("products/attributes/{$attributeId}/terms", ['per_page' => 100])->throw()->json());
}
}
This will cache each WooCommerce response for one hour (Laravel cache lifetimes are given in seconds, so 3600). Next, we need to update our Livewire component so the filter definitions from our own database are cached too, by replacing the FilterDefinition::all() call in render(). Call Cache::forget('product-filters') whenever you add or edit a filter:
// app/Livewire/ProductFilter.php
use Illuminate\Support\Facades\Cache;
// ...inside render():
$filters = Cache::remember('product-filters', 3600, fn () => FilterDefinition::all());
Minification
To minify our CSS and JavaScript files, we don’t need an extra package: Vite, which Laravel uses to bundle front-end assets, minifies both when it builds for production. First, make sure your layout loads the assets through the @vite directive:
@vite(['resources/css/app.css', 'resources/js/app.js'])
Next, if you want to make the minification settings explicit, add them to the build section of your vite.config.js file:
// vite.config.js
export default defineConfig({
// ...
build: {
minify: true,
cssMinify: true,
},
});
Now, run the following commands to build minified assets into public/build and cache Laravel’s configuration, routes, and views:
npm run build
php artisan optimize
This should significantly improve our product filter’s performance. With these caching and minification techniques in place, we’ve completed our custom WooCommerce product filter with Tailwind CSS and Livewire.
Frequently Asked Questions
What is the best way to manage complex product filtering for WooCommerce customers?
Building a custom WooCommerce product filter using Tailwind CSS and Livewire can help manage complex product filtering efficiently.
Why do third-party plugins or customized default filters often break with new updates?
This can happen due to compatibility issues or changes in the underlying code of WooCommerce, resulting in broken functionality.
How does Tailwind CSS contribute to building a custom WooCommerce product filter?
Tailwind CSS provides utility-first styling that allows for rapid development and customization of the product filter UI.
What is server-side rendering, and how is it used in this tutorial with Livewire?
Server-side rendering uses the power of your server to render dynamic content before sending it to the client’s browser, improving performance and security.
Can I achieve similar results without using Tailwind CSS or Livewire?
While possible, using a combination of HTML, CSS, and JavaScript can be more time-consuming and may not provide the same level of customization and efficiency as using Tailwind CSS and Livewire.
