A robust and flexible filtering system for Laravel Eloquent models that makes building complex, dynamic queries a breeze. This package provides an elegant, fluent API for filtering, searching, and sorting your Eloquent models with minimal configuration.
- Expressive Filtering: Chainable methods and intuitive syntax for complex queries
- Advanced Search: Full-text search with fuzzy matching and term normalization
- Relationship Support: Filter across model relationships with nested conditions
- Type Safety: Strict type checking and automatic value casting
- Performance Optimized: Efficient query building with minimal overhead
- Security First: Whitelisting and input validation out of the box
- Extensible: Easy to create and register custom filters
- Modern PHP: Built with PHP 8.1+ features and type hints
- PHP 8.1 or higher
- Laravel 10.x or later
- Composer
composer require dibakar/laravel-dynamic-filters
Publish the configuration file to customize the package behavior:
php artisan vendor:publish --provider="Dibakar\LaravelDynamicFilters\DynamicFiltersServiceProvider" --tag="config"
This will create a dynamic-filters.php
file in your config
directory with sensible defaults.
The package uses Laravel's package auto-discovery, but you can manually register it in config/app.php
if needed:
'providers' => [
// Other service providers...
Dibakar\LaravelDynamicFilters\DynamicFiltersServiceProvider::class,
],
'aliases' => [
// Other aliases...
'DynamicFilter' => Dibakar\LaravelDynamicFilters\Facades\DynamicFilter::class,
],
Laravel | PHP | Package |
---|---|---|
12.x | 8.2+ | ^1.0 |
11.x | 8.2+ | ^1.0 |
10.x | 8.1+ | ^1.0 |
Add the HasDynamicFilter
trait to your Eloquent model and define the filterable, searchable, and sortable fields:
use Dibakar\LaravelDynamicFilters\Traits\HasDynamicFilter;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasDynamicFilter;
/**
* Fields that can be searched.
*/
protected $searchable = [
'title',
'content',
'author.name', // Search in relationships
'tags.name' // Search in many-to-many relationships
];
/**
* Fields that can be filtered with operators.
*/
protected $filterable = [
'id',
'status',
'category_id',
'published_at',
'views',
'is_featured',
];
/**
* Fields that can be used for sorting.
*/
protected $sortable = [
'created_at' => 'desc', // Default sort
'title' => 'asc',
'views' => 'desc',
];
/**
* Default filter presets.
*/
protected $filterPresets = [
'published' => [
'status' => 'published',
'sort' => '-published_at',
],
'popular' => [
'views' => ['gt' => 1000],
'is_featured' => true,
'sort' => '-views',
],
];
'status', // Simple filter: ?status=published
'category_id', // Exact match: ?category_id=5
'created_at' => [ // Date filtering
'operators' => ['=', '>', '<', '>=', '<=', '!='],
'cast' => 'date',
],
'views' => [ // Numeric filtering
'operators' => ['=', '>', '<', '>=', '<=', '!='],
'cast' => 'int',
],
'author_id' => 'author.id', // Relationship filtering
'tag_id' => 'tags.id' // Many-to-many relationship
];
/**
* Get the default filters that should be applied to all queries.
*
* @return array
*/
public function getDefaultFilters()
{
return [
'status' => 'published',
'sort' => '-created_at',
];
}
}
Filter your models using query parameters in your controller:
// GET /posts?status=published&created_at[gt]=2023-01-01&sort=-views,title
public function index(Request $request)
{
$posts = Post::filter($request->query())
->with(['author', 'category', 'tags']) // Eager load relationships
->paginate($request->per_page ?? 15);
return response()->json($posts);
}
Search across searchable fields with a simple API:
// GET /posts?q=laravel+framework
public function search(Request $request)
{
$posts = Post::search($request->q)
->filter($request->except('q')) // Apply additional filters
->paginate($request->per_page ?? 15);
return response()->json($posts);
}
Sort your results using the sort
parameter in your requests. The -
prefix indicates descending order.
// In your controller
$posts = Post::sort($request->input('sort'))->get();
// Or chain it with filters
$posts = Post::filter($filters)
->sort($request->input('sort', 'created_at,desc'))
->paginate(15);
// Example requests:
// GET /posts?sort=title // Sort by title (ascending)
// GET /posts?sort=title,asc // Same as above (explicit ascending)
// GET /posts?sort=title,desc // Sort by title (descending)
// GET /posts?sort=-title // Alternative: Sort by title (descending)
// GET /posts?sort=views,desc&sort=title,asc // Multiple sort fields
// GET /posts?sort=created_at,desc // Sort by created_at (newest first)
// GET /posts?sort=author.name,asc // Sort by relationship field
Define default sorting in your model:
// In your model
protected $sortable = [
'created_at' => 'desc', // Default sort
'title' => 'asc',
'views' => 'desc',
'author.name' => 'asc', // Sort by relationship
];
// In your controller
public function index(Request $request)
{
$validated = $request->validate([
'sort' => 'sometimes|string',
// other validation rules
]);
return Post::filter($request->except('sort'))
->sort($validated['sort'] ?? null)
->paginate($request->per_page ?? 15);
}
field
- Sort ascending-field
- Sort descendingrelation.field
- Sort by relationship field
Pagination works seamlessly with Laravel's built-in pagination:
// GET /posts?page=2&per_page=20
$posts = Post::filter($request->query())
->paginate($request->per_page ?? 15);
Create complex filter conditions with AND/OR logic:
// Example: (status = 'published' AND (title LIKE '%Laravel%' OR views > 100)) AND (author_id = 1 OR author_id = 2)
$filters = [
'_group' => [
'boolean' => 'and',
'filters' => [
'status' => 'published',
],
'nested' => [
[
'boolean' => 'or',
'filters' => [
'title' => ['like' => '%Laravel%'],
'views' => ['gt' => 100],
],
],
[
'boolean' => 'or',
'filters' => [
'author_id' => [1, 2],
],
],
],
],
];
$posts = Post::filter($filters)->get();
For complex filtering logic, create a custom filter class:
<?php
namespace App\Filters;
use Dibakar\LaravelDynamicFilters\Contracts\FilterContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class PublishedInLastDaysFilter implements FilterContract
{
/**
* Apply the filter to the query.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param mixed $value
* @param string $property
* @return \Illuminate\Database\Eloquent\Builder
*/
public function apply(Builder $query, $value, string $property): Builder
{
$days = is_numeric($value) ? (int) $value : 7; // Default to 7 days if invalid
return $query->where('published_at', '>=', Carbon::now()->subDays($days));
}
/**
* Validate the filter value.
*
* @param mixed $value
* @return bool
*/
public function validate($value): bool
{
return is_numeric($value) && $value > 0;
}
/**
* Get the validation error message.
*
* @return string
*/
public function getValidationMessage(): string
{
return 'The days parameter must be a positive number.';
}
}
Register your custom filter in config/dynamic-filters.php
:
'custom_filters' => [
'published_in_days' => \App\Filters\PublishedInLastDaysFilter::class,
'active_users' => \App\Filters\ActiveUsersFilter::class,
// Add more custom filters as needed
],
Now use it in your API:
// GET /posts?published_in_days=30
$recentPosts = Post::filter(request()->query())->get();
Define common filter presets in your configuration:
// config/dynamic-filters.php
'presets' => [
'recent' => [
'created_at' => ['gt' => now()->subMonth()->toDateString()],
'sort' => '-created_at',
],
'popular' => [
'views' => ['gt' => 1000],
'status' => 'published',
'sort' => '-views,title',
],
'featured' => [
'is_featured' => true,
'status' => 'published',
'sort' => '-created_at',
],
],
Use presets in your API:
# Get recent posts
GET /posts?preset=recent
# Get popular posts
GET /posts?preset=popular
# Combine with additional filters
GET /posts?preset=featured&category=laravel
Easily integrate with Laravel's API Resources:
// app/Http/Resources/PostResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->when($this->showFullContent, $this->content),
'status' => $this->status,
'views' => $this->views,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'author' => UserResource::make($this->whenLoaded('author')),
'category' => CategoryResource::make($this->whenLoaded('category')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
];
}
}
// In your controller
public function index(Request $request)
{
$posts = Post::filter($request->query())
->with(['author', 'category', 'tags'])
->paginate($request->per_page ?? 15);
return PostResource::collection($posts);
}
// In a migration
public function up()
{
Schema::table('posts', function (Blueprint $table) {
// Single column indexes
$table->index('status');
$table->index('published_at');
$table->index('views');
// Composite index for common filter combinations
$table->index(['status', 'published_at']);
$table->index(['category_id', 'status', 'published_at']);
});
}
// Only select the fields you need
$posts = Post::select([
'id',
'title',
'slug',
'excerpt',
'status',
'published_at',
'author_id',
'category_id'
])
->with([
'author:id,name,avatar',
'category:id,name,slug',
'tags:id,name,slug'
])
->filter($filters)
->paginate(15);
-
Index Your Database: Add indexes to columns used in filtering, searching, and sorting to improve query performance.
-
Use Select Wisely: Only select the columns you need to reduce memory usage.
$posts = Post::filter($filters) ->select('id', 'title', 'created_at') ->with('author:id,name') ->get();
-
Eager Load Relationships: Use
with()
to avoid N+1 query problems.$posts = Post::filter($filters) ->with(['author', 'category']) ->get();
-
Limit Result Size: Always use pagination or limit for large datasets to improve performance.
$posts = Post::filter($filters)->paginate(15);
-
Consider Caching: For expensive queries, consider implementing caching at the application level using Laravel's caching system.
Handle filter validation errors gracefully:
try {
$posts = Post::filter($request->query())->paginate(15);
return PostResource::collection($posts);
} catch (\Dibakar\LaravelDynamicFilters\Exceptions\InvalidFilterException $e) {
return response()->json([
'message' => 'Invalid filter parameters',
'errors' => $e->getMessage()
], 400);
} catch (\Exception $e) {
return response()->json([
'message' => 'An error occurred while processing your request.',
'error' => config('app.debug') ? $e->getMessage() : 'Server error'
], 500);
}
Write tests to ensure your filters work as expected:
// tests/Feature/PostFilterTest.php
public function test_can_filter_posts_by_status()
{
$published = Post::factory()->create(['status' => 'published']);
$draft = Post::factory()->create(['status' => 'draft']);
$response = $this->getJson('/api/posts?status=published');
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonFragment(['id' => $published->id])
->assertJsonMissing(['id' => $draft->id]);
}
public function test_can_search_posts()
{
$laravelPost = Post::factory()->create([
'title' => 'Getting Started with Laravel',
'content' => 'Laravel is a web application framework...'
]);
$symfonyPost = Post::factory()->create([
'title' => 'Symfony vs Laravel',
'content' => 'Comparison between the two frameworks...'
]);
$response = $this->getJson('/api/posts?q=laravel');
$response->assertStatus(200)
->assertJsonCount(2, 'data')
->assertJsonFragment(['id' => $laravelPost->id])
->assertJsonFragment(['id' => $symfonyPost->id]);
}
Operator | Description | Example |
---|---|---|
= | Equals | ?status=active |
!= | Not equals | ?status[neq]=inactive |
> | Greater than | ?views[gt]=100 |
>= | Greater than or equal | ?rating[gte]=4 |
< | Less than | ?price[lt]=100 |
<= | Less than or equal | ?age[lte]=30 |
like | Like (case-sensitive) | ?name[like]=%john% |
ilike | Like (case-insensitive) | ?email[ilike]=%gmail.com |
in | In array | ?status[in]=active,pending |
not_in | Not in array | ?id[not_in]=1,2,3 |
between | Between values | ?created_at[between]=2023-01-01,2023-12-31 |
null | Is null | ?deleted_at[null] |
notnull | Is not null | ?updated_at[notnull] |
By default, only fields defined in the $filterable
array can be filtered. This is a security measure to prevent unauthorized filtering on sensitive fields.
You can also define a global whitelist in the config file that applies to all models:
'global_whitelist' => [
'id',
'status',
'created_at',
'updated_at',
],
Please see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
If you discover any security related issues, please email dibakarmitra07@gmail.com instead of using the issue tracker.
The MIT License (MIT). Please see License File for more information.