Skip to content

Facet filtering for Laravel projects, fully customizable, batteries included.

License

Notifications You must be signed in to change notification settings

mgussekloo/laravel-facet-filter

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Laravel Facet Filter

This package provides simple facet filtering (sometimes called Faceted Search or Faceted Navigation) in Laravel projects. It helps narrow down query results based on the attributes of your models.

  • Free, no dependencies
  • Easy to use in any project
  • Easy to customize
  • There's a demo project to get you started

Demo

Contributing

Please contribute to this package, either by creating a pull request or reporting an issue.

Installation

This package can be installed through Composer.

composer require mgussekloo/laravel-facet-filter

Prepare your project

Update your models

Add a Facettable trait and a facetDefinitions() method to models that should support facet filtering.

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

use Mgussekloo\FacetFilter\Traits\Facettable;

class Product extends Model
{
	use HasFactory;
	use Facettable;

	public static function facetDefinitions()
	{
		// Return an array of definitions
		return [
			[
				'title' => 'Main color', // The title will be used for the parameter.
				'fieldname' => 'color' // Model property from which to get the values.
			],
			[
				'title' => 'Sizes',
				'fieldname' => 'sizes.name' // Use dot notation to get the value from related models.
			]
		];
	}
}

Publish and run the migrations

For larger datasets you must build an index of all facets beforehand. If you're absolutely certain you don't need an index, skip to filtering collections.

php artisan vendor:publish --tag="facet-filter-migrations"
php artisan migrate

Build the index

Now you can start building the index. There's a simple Indexer included, you just need to configure it to run once, periodically or whenever a relevant part of your data changes.

use Mgussekloo\FacetFilter\Indexer;

$products = Product::with(['sizes'])->get(); // get some products

$indexer = new Indexer();

$indexer->resetIndex(); // clear the entire index or...
$indexer->resetRows($products); // clear only the models that you know have changed

$indexer->buildIndex($products); // process the models

Get results

Apply the facet filter to a query

$filter = request()->all(); // use the request parameters
$filter = ['main-color' => ['green']]; // (or provide your own array)

$products = Product::facetFilter($filter)->get();

Build the frontend

$facets = Product::getFacets();

/* You can filter and sort like any regular Laravel collection. */
$singleFacet = $facets->firstWhere('fieldname', 'color');

/* Find out stuff about the facet. */
$paramName = $singleFacet->getParamName(); // "main-color"
$options = $singleFacet->getOptions();

/*
Options look like this:
(object)[
	'value' => 'Red',
	'selected' => false,
	'total' => 3,
	'slug' => 'color_red',
	'http_query' => 'main-color%5B1%5D=red&sizes%5B0%5D=small'
]
*/

Basic frontend example

Here's a simple demo project that demonstrates a basic frontend.

<div class="flex">
	<div class="w-1/4 flex-0">
		@foreach ($facets as $facet)
			<p>
				<h3>{{ $facet->title }}</h3>

				@foreach ($facet->getOptions() as $option)
					<a href="?{{ $option->http_query }}" class="{{ $option->selected ? 'underline' : '' }}">{{ $option->value }} ({{ $option->total }}) </a><br />
				@endforeach
			</p><br />
		@endforeach
	</div>
	<div class="w-3/4">
		@foreach ($products as $product)
			<p>
				<h1>{{ $product->name }} ({{ $product->sizes->pluck('name')->join(', ') }})</h1>
				{{ $product->color }}<br /><br />
			</p>
		@endforeach
	</div>
</div>

Livewire example

This is how it could look like with Livewire.

<h2>Colors</h2>
@foreach ($facet->getOptions() as $option)
	<div class="facet-checkbox-pill">
		<input
			wire:model="filter.{{ $facet->getParamName() }}"
			type="checkbox"
			id="{{ $option->slug }}"
			value="{{ $option->value }}"
		/>
		<label for="{{ $option->slug }}" class="{{ $option->selected ? 'selected' : '' }}">
			{{ $option->value }} ({{ $option->total }})
		</label>
	</div>
@endforeach

Customization

Advanced indexing

Extend the Indexer to customize behavior, e.g. to save a "range bracket" value instead of a "individual price" value to the index.

class MyCustomIndexer extends \Mgussekloo\FacetFilter\Indexer {
	public function buildValues($facet, $model) {
		$values = parent::buildValues($facet, $model);

		if ($facet->fieldname == 'price') {

			if ($model->price > 1000) {
				return 'Expensive';
			}
			if ($model->price > 500) {
				return '500 - 1000';
			}
			if ($model->price > 250) {
				return '250 - 500';
			}
			return '0 - 250';
		}

		return $values;
	}
}

Incremental indexing for large datasets

$perPage = 1000; $currentPage = Cache::get('facetIndexingPage', 1);

$products = Product::with(['sizes'])->paginate($perPage, ['*'], 'page', $currentPage);
$indexer = new Indexer($products);

if ($currentPage == 1) {
	$indexer->resetIndex();
}

$indexer->buildIndex();

if ($products->hasMorePages()) {}
	// next iteration, increase currentPage with one
}

Custom facets

Provide custom attributes and an optional custom Facet class in the facet definitions.

public static function facetDefinitions()
{
	return [
		[
			'title' => 'Main color',
			'description' => 'The main color.', // optional custom attribute, you could use $facet->description when creating the frontend...
            'related_id' => 23, // ... or use $facet->related_id with your custom indexer 
			'fieldname' => 'color',
			'facet_class' => CustomFacet::class // optional Facet class with custom logic
		]
	];
}

Filtering collections

It's possible to apply facet filtering to a collection, without building an index. Models with the Facettable trait return a FacettableCollection which has an indexlessFacetFilter() method. It's slower than filtering with an index, though.

$products = Product::all(); // returns a "FacettableCollection"
$products = $products->indexlessFacetFilter($filter);

// the second (optional) parameter lets you specify which indexer to use when indexing values from models

$indexer = new App\MyCustomIndexer();
$products = Product::all()->indexlessFacetFilter($filter, $indexer);

Notes on caching

By default Facet Filter uses the non-persistent 'array' cache driver, with queries and calculations happening every request. You can configure the cache driver (as well as the expiration time and cachekey prefix) through config/facet-filter.php

If you decide to use a persistent cache driver, please note the following:

  • Results counts and facet rows are cached per the "subject" class + the applied filter. Caching does not take into account other factors that might impact the result count, such as query constraints, users being logged in or not, etc.
  • That's why calls to facetFilter() or indexlessFacetFilter() always clear cached result counts before running. You can use the withCache() method to change this behaviour.
	// do not clear the result count cache before facet filtering (only useful if using a persistent caching driver)
	Product::withCache()->facetFilter($filter)->get();

	// using collection-based facet filtering
	Projects::all()->withCache()->indexlessFacetFilter($filter);
  • The default Indexer clears the cache automatically when rebuilding the index. To do it manually:
	FacetFilter::forgetCache(); // clears all result counts for all facets, and all facet rows

Config

    'classes' => [
        'facet' => Mgussekloo\FacetFilter\Models\Facet::class,
        'facetrow' => Mgussekloo\FacetFilter\Models\FacetRow::class,
    ],

    'table_names' => [
		'facetrows' => 'facetrows',
	],

    'cache' => [
        'expiration_time' => \DateInterval::createFromDateString('24 hours'),
        'key' => 'mgussekloo.facetfilter.cache',
        'store' => 'array',
    ],

License

The MIT License (MIT). Please see License File for more information.

About

Facet filtering for Laravel projects, fully customizable, batteries included.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages