├── LICENSE ├── README.md ├── composer.json ├── src ├── Commands │ └── FilterMakeCommand.php ├── Concerns │ └── Filterable.php ├── Contracts │ └── Filter.php └── SieveServiceProvider.php └── stubs └── filter.stub /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Osama Aldemeery 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sieve - Clean & Easy Eloquent Filtration 2 | 3 |

4 | Build Status 5 | Total Downloads 6 | Latest Version 7 | License 8 |

9 | 10 | * [Installation](#installation) 11 | * [Usage](#usage) 12 | * [Creating Filters](#creating-filters) 13 | * [Filtering](#filtering) 14 | * [Mapping Values](#mapping-values) 15 | 16 | A minimalist, ultra-lightweight package for clean, intuitive query filtering. 17 | 18 | With Sieve, your filtration logic is simplified from something like this: 19 | 20 | ```php 21 | public function index(Request $request) 22 | { 23 | $query = Product::query(); 24 | 25 | if ($request->has('color')) { 26 | $query->where('color', $request->get('color')); 27 | } 28 | 29 | if ($request->has('condition')) { 30 | $query->where('condition', $request->get('condition')); 31 | } 32 | 33 | if ($request->has('price')) { 34 | $direction = $request->get('price') === 'highest' ? 'desc' : 'asc'; 35 | $query->orderBy('price', $direction); 36 | } 37 | 38 | return $query->get(); 39 | } 40 | ``` 41 | 42 | to this: 43 | 44 | ```php 45 | public function index(Request $request) 46 | { 47 | return Product::filter($request->query())->get(); 48 | } 49 | ``` 50 | 51 | --- 52 | 53 | ## Installation 54 | 55 | > [!IMPORTANT] 56 | > This package requires Laravel 11.0 or higher and PHP 8.2 or higher. 57 | 58 | You can install the package via composer: 59 | 60 | ```bash 61 | composer require aldemeery/sieve 62 | ``` 63 | 64 | --- 65 | 66 | ## Usage 67 | 68 | Enabling filtration for a model is as easy as adding the `Aldemeery\Sieve\Concerns\Filterable` trait to it: 69 | 70 | ```php 71 | use Aldemeery\Sieve\Concerns\Filterable; 72 | use Illuminate\Database\Eloquent\Model; 73 | 74 | class Product extends Model 75 | { 76 | use Filterable; 77 | 78 | // ... 79 | } 80 | ``` 81 | The `Filterable` trait introduces a `filter` [local scope](https://laravel.com/docs/eloquent#local-scopes) to your model, which accepts an associative array for filtration: 82 | 83 | ```php 84 | public function index(Request $request) 85 | { 86 | return Product::filter($request->query())->get(); 87 | } 88 | ``` 89 | 90 | Now you're ready to create your filter classes. 91 | 92 | --- 93 | 94 | ### Creating filters 95 | 96 | To create a filter, create a class that implements the `Aldemeery\Sieve\Contracts\Filter` interface. 97 | 98 | You can either create a filter class using the `make:filter` artisan command, which will place the filter in the `app/Http/Filters` directory. 99 | Alternatively, you can create a filter class manually and place it wherever you prefer: 100 | 101 | ```bash 102 | php artisan make:filter Product/ColorFilter 103 | ``` 104 | 105 | This generates a `ColorFilter` class in the `app/Filters/Product` directory: 106 | 107 | ```php 108 | */ 116 | class ColorFilter implements Filter 117 | { 118 | public function map(mixed $value): mixed 119 | { 120 | return match ($value) { 121 | default => $value, 122 | }; 123 | } 124 | 125 | public function apply(Builder $query, mixed $value): void 126 | { 127 | // $query->where('id', $value); 128 | } 129 | } 130 | ``` 131 | 132 | Here, `apply` defines the filtration logic, while `map` can transform input values if needed before passing them to `apply` 133 | 134 | > [!IMPORTANT] 135 | > Before a value is passed to the `apply` method, it's first passed to the `map` method. 136 | > 137 | > If you do not need to map values into other values, you should just leave the `map` method as it is. 138 | 139 | Check out this examples: 140 | 141 | ```php 142 | public function map(mixed $value): mixed 143 | { 144 | return match ($value) { 145 | 'yes' => true, 146 | 'no' => false, 147 | '1' => true, 148 | '0' => true, 149 | default => $value, 150 | }; 151 | } 152 | 153 | public function apply(Builder $query, mixed $value): void 154 | { 155 | // Assuming filter was called like this: Product::filter(['in_stock' => 'yes'])->get(); 156 | // Or like this: Product::filter(['in_stock' => '1'])->get(); 157 | // In both cases, $value would be `true` 158 | 159 | $query->where('in_stock', $value); 160 | } 161 | ``` 162 | 163 | With an instance of `Illuminate\Database\Eloquent\Builder` passed to `apply`, you gain access to its full capabilities, allowing you to perform a wide range of operations: 164 | 165 | #### Example 1 - Ordering: 166 | 167 | ```php 168 | public function apply(Builder $query, mixed $value): void 169 | { 170 | $query->orderBy('price', $value); 171 | } 172 | ``` 173 | 174 | #### Example 2 - Relations: 175 | 176 | ```php 177 | public function apply(Builder $query, mixed $value): void 178 | { 179 | $query->whereHas('category', function($query) use ($value): void { 180 | $query->where('name', $value); 181 | }); 182 | } 183 | ``` 184 | 185 | --- 186 | 187 | ### Filtering 188 | 189 | Once you have created your filters and defined your filtration logic, It's time now to actually use the filter, which can be done in two ways: 190 | - [Passing a filters array](#passing-a-filters-array) as a second parameter to the `filter` scope. 191 | - [Defining model filters](#defining-model-filters) inside the model itself. 192 | 193 | #### Passing a filters array: 194 | 195 | Use this when you want to apply a filter to a single query: 196 | 197 | ```php 198 | public function index(Request $request) 199 | { 200 | return Product::filter($request->query(), [ 201 | // "color" here is the key to be used in the query string 202 | // e.g. https://example.com/products?color=red 203 | "color" => \App\Filters\Product\ColorFilter::class, 204 | ])->get(); 205 | } 206 | ``` 207 | 208 | In the above example, the `ColorFilter` is applied *only* for this query. 209 | 210 | #### Defining model filters: 211 | Alternatively, if you want a filter to be associated with a model and applied every time the filter method is called, you can add a `filters` method to your model that returns an array mapping keys to their corresponding filter classes: 212 | 213 | ```php 214 | */ 226 | private function filters(): array 227 | { 228 | return [ 229 | 'color' => \App\Filters\Product\ColorFilter::class, 230 | ]; 231 | } 232 | } 233 | ``` 234 | 235 | Now everytime you call the `filter` method on the model, you will have the `ColorFilter` applied to your query: 236 | 237 | ```php 238 | public function index(Request $request) 239 | { 240 | // The `ColorFilter` filter is applied. 241 | return Product::filter($request->query())->get(); 242 | } 243 | ``` 244 | 245 | > [!IMPORTANT] 246 | > Only filters with keys present in the data array will be applied. Any filters not included in the array will be ignored. 247 | > 248 | > For instance, if your filter array includes only the `color` key, only the corresponding `ColorFilter` will be executed, while any other filters will have no effect on the query. 249 | 250 | --- 251 | 252 | ### Mapping Values 253 | In some cases, you may want to use more user-friendly values that do not directly correspond to the values needed for filtration. 254 | This is where the map method comes in handy. 255 | 256 | Before any value reaches the `apply` method, it is first processed by the map method. 257 | This allows you to transform incoming values into something more meaningful for your application. 258 | 259 | #### Example: 260 | Imagine you want to sort products by price but using the query string, but you prefer using labels like `..?price=lowest` or `..?price=highest` instead of technical terms like `..?price=asc` or `..?price=desc`. 261 | 262 | You can achieve this by using the `map` method, as shown below: 263 | 264 | ```php 265 | */ 273 | class PriceFilter implements Filter 274 | { 275 | public function map(mixed $value): mixed 276 | { 277 | return match ($value) { 278 | 'lowest' => 'asc', 279 | 'highest' => 'desc', 280 | default => $value, 281 | }; 282 | } 283 | 284 | public function apply(Builder $query, mixed $value): void 285 | { 286 | // After mapping, $value will be 'asc' for 'lowest' and 'desc' for 'highest'. 287 | $query->orderBy('price', $value); 288 | } 289 | } 290 | ``` 291 | 292 | With this implementation, you can present a more intuitive interface to users while maintaining the necessary functionality for sorting in your queries. 293 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aldemeery/sieve", 3 | "description": "A simple, clean and elegant way to filter Eloquent models.", 4 | "keywords": ["laravel", "eloquent", "filters", "search", "query", "filter", "filtration"], 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Osama Aldemeery", 10 | "email": "aldemeery@gmail.com" 11 | } 12 | ], 13 | "minimum-stability": "stable", 14 | "require": { 15 | "php": "^8.2", 16 | "illuminate/support": "^11.0|^12.0", 17 | "illuminate/database": "^11.0|^12.0", 18 | "illuminate/console": "^11.0|^12.0", 19 | "aldemeery/onion": "^1.0" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Aldemeery\\Sieve\\": "src/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Tests\\Aldemeery\\Sieve\\": "tests/" 29 | } 30 | }, 31 | "extra": { 32 | "laravel": { 33 | "providers": [ 34 | "Aldemeery\\Sieve\\SieveServiceProvider" 35 | ] 36 | } 37 | }, 38 | "require-dev": { 39 | "laravel/pint": "^1.16", 40 | "laravel/sail": "^1.29", 41 | "phpstan/phpstan": "^1.11", 42 | "phpstan/phpstan-phpunit": "^1.4", 43 | "phpunit/phpunit": "^11.1", 44 | "squizlabs/php_codesniffer": "^3.10", 45 | "symfony/var-dumper": "^7.0", 46 | "thecodingmachine/phpstan-safe-rule": "^1.2", 47 | "infection/infection": "^0.29.7", 48 | "mockery/mockery": "^1.6" 49 | }, 50 | "scripts": { 51 | "lint": "pint --test", 52 | "lint:fix": "pint", 53 | "sniff": "phpcs --extensions=php", 54 | "sniff:fix": "phpcbf --extensions=php", 55 | "analyze:phpstan": "phpstan analyse --memory-limit=6G", 56 | "test": "phpunit", 57 | "test:mutate": [ 58 | "Composer\\Config::disableProcessTimeout", 59 | "infection --threads=12" 60 | ], 61 | "code:check": [ 62 | "@lint", 63 | "@sniff", 64 | "@analyze:phpstan", 65 | "@test", 66 | "@test:mutate" 67 | ] 68 | }, 69 | "config": { 70 | "allow-plugins": { 71 | "infection/extension-installer": true 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Commands/FilterMakeCommand.php: -------------------------------------------------------------------------------- 1 | $query 18 | * @param array $params 19 | * @param array $additional 20 | */ 21 | protected function scopeFilter(Builder $query, array $params = [], array $additional = []): void 22 | { 23 | Onion\onion([ 24 | fn (array $filters): array => array_merge($filters, $additional), 25 | fn (array $filters): array => array_intersect_key($filters, $params), 26 | fn (array $filters): array => array_map( 27 | function (string $filter): Filter { 28 | $filter = App::make($filter); 29 | 30 | if (!$filter instanceof Filter) { 31 | throw new RuntimeException(sprintf( 32 | 'Filters must implement %s, but %s does not.', 33 | Filter::class, 34 | is_object($filter) ? get_class($filter) : gettype($filter), 35 | )); 36 | } 37 | 38 | return $filter; 39 | }, 40 | $filters, 41 | ), 42 | fn (array $filters): true => array_walk( 43 | $filters, 44 | fn (Filter $filter, string $key) => $filter->apply($query, $filter->map($params[$key])), 45 | ), 46 | ])->withoutExceptionHandling()->peel($this->filters()); 47 | } 48 | 49 | /** @return array */ 50 | private function filters(): array 51 | { 52 | return [ 53 | // "filter-name" => \FilterCalss::class 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Contracts/Filter.php: -------------------------------------------------------------------------------- 1 | $query */ 16 | public function apply(Builder $query, mixed $value): void; 17 | } 18 | -------------------------------------------------------------------------------- /src/SieveServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 15 | $this->commands([ 16 | FilterMakeCommand::class, 17 | ]); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /stubs/filter.stub: -------------------------------------------------------------------------------- 1 | */ 9 | class DummyClass implements Filter 10 | { 11 | public function map(mixed $value): mixed 12 | { 13 | return match ($value) { 14 | default => $value, 15 | }; 16 | } 17 | 18 | public function apply(Builder $query, mixed $value): void 19 | { 20 | // $query->where('id', $value); 21 | } 22 | } 23 | --------------------------------------------------------------------------------