DEV Community

Amirhosein Zlf
Amirhosein Zlf

Posted on

Dynamic Filters With Laravel Eloquent

Dynamic Filters With Laravel Eloquent

Did you ever had a Laravel application and needs filters over your API? You might have tried GraphQL, however, you could write a simple one and don’t need any integrations.

In this article I’m going to implement a simple service which could work with Laravel eloquent ORM, it would handle relations and also the basic operators which uses the most.

The Idea

You are developing a RESTful API and want to let your users filter the indexes with different fields or even on the relations of your model.

For instance, a simple article model which has so many tags and comments , also a user who owns it. Now you have an API which lists all the articles /api/articles . How could you let the user filter over the tags or comments? How could you filter all the articles of a specific user? Moreover, how could you combine all of them together?

Let’s add a filter to our API to find all the articles of user with id = 1.

/api/articles?filters[]=user.id=1

Seems cool? Now I want the articles of user 1 which have a specific tag.

/api/articles?filters[]user.id=1&filters[]=tags.name=ipsa

In the next step, we are going to setup the basic project and then start adding these dynamic filters to that.

Setup

I was looking for a real world sample project and found out there is a complete Laravel application which could be used. Take a look at their github repository and walk through the *README *to set it up.

https://github.com/gothinkster/laravel-realworld-example-app

Extend the Idea

We know that there is a query param called filters which contains the relation/field, operator and the value.

Here we are going to implement the basic logical operators which are = , != , > , < , >= and <= . Also I have added 2 custom operators ~ and () .

~ would work as LIKE in queries with % around it.

e.x. /api/articles?filters[]=slug~real-world

SQL: SELECT * FROM articles where slug like '%real-world%';

() would work as IN in queries.

e.x. /api/articles?filters[]user.id()1,2

SQL: SELECT * FROM articles where user_id IN(1,2);

Dive into coding

The Service

First we need to create a service which could take the request and create the appropriate queries on given model for us.

Create a Services directory under app folder, then create a FilterQueryBuilder.php file inside it.

# app/Services/FilterQueryBuilder.php

<?php
namespace App\Services;

use Illuminate\Database\Eloquent\Builder;

class FilterQueryBuilder {
  protected $request;

  public function __construct($request) {
     $this->request = $request;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let’s add the code step by step.

We need a public method on this class which would accept an eloquent builder, parse the filters and add the appropriate queries to it. Now let’s add the method below to our service.

# app/Services/FilterQueryBuilder.php
...

public function buildQuery($query) {
    $filters = $this->request->query('filters');
    $filters = $this->parseFilters($filters);
    foreach($filters as $filter) {
      $query = $this->addFiltersToQuery($query, $filter);
    }

  return $query;
}
...
Enter fullscreen mode Exit fullscreen mode

Parsing the filters

For each given filter in the query parameter, we need to find the relations/field, operator and the value then we are able to add these to our query.

Let’s add a getOperator method which accept the filter string value and return the operator for us.

# app/Services/FilterQueryBuilder.php
...
private function getOperator($filter) {
    $operatorsPattern = '/=|!=|\(\)|>=|<=|~|>|</';
    $operator = [];
    preg_match($operatorsPattern, $filter, $operator);
    if(count($operator) == 1) {
      return $operator[0];
    }
}
...
Enter fullscreen mode Exit fullscreen mode

Thanks to regular expressions, it’s super easy to find the operator. If you are not familiar with regex, read more about it here. Also you could practice these methods online with PHP Live Regex website.

Now that we have the operator, we could split the filter string into relations/field and value part. Let’s add the code for it.

# app/Services/FilterQueryBuilder.php
...

private function getFilterValue($filter, $operator) {
    $result = explode($operator, $filter);
    if(count($result) == 2) {
      return $result[1];
    }
}

private function getFilterRelations($filter, $operator) {
    $result = explode($operator, $filter);
    if(count($result) == 2) {
      return $result[0];
    }
}
...
Enter fullscreen mode Exit fullscreen mode

Relation Tree

So far we have found the relations, operator and value in our filter. How we could add these relation to our query?

For example, for our first API example, we need to create a nested array like this.

# /api/articles?filters[]user.id()1,2

Array
(
    [user] => Array
        (
            [field] => id
            [operator] => ()
            [value] => 1,2
        )

)
Enter fullscreen mode Exit fullscreen mode

For creating the relation tree, we need the relation keys, operator and value which have founded already.

# app/Services/FilterQueryBuilder.php
...
private function createRelationTree(string $relationKeys, $operator, $value) {
    $relations = explode('.', $relationKeys);
    $lastKey = array_key_last($relations);
    $field = $relations[$lastKey];
    unset($relations[$lastKey]);
    $result = [];
    if(count($relations) > 0 ) {
      $result[$relations[count($relations)-1]] = [
        'field' => $field,
        'operator' => $operator,
        'value' => $value
      ];
      for($i=count($relations)-2; $i>-1; $i--)
      {
        $result[$relations[$i]] = $result;
        unset($result[$relations[$i+1]]);
      }
    } else {
      $result = [
        'field' => $field,
        'operator' => $operator,
        'value' => $value
      ];
    }

  return $result;
}
...
Enter fullscreen mode Exit fullscreen mode

This code will split all the relation and convert it into a nested array. If we have a filter like model1.model2.model3.id=100 then the output would be something like this.

Array
(
    [model1] => Array
        (
            [model2] => Array
                (
                    [model3] => Array
                        (
                            [field] => id
                            [operator] => =
                            [value] => 100
                        )

                 )

         )

)
Enter fullscreen mode Exit fullscreen mode

Now let’s add these methods into one method and aggregate all the filters together.

# app/Services/FilterQueryBuilder.php
...
private function parseFilters($filters) {
    if(empty($filters)) {
      return [];
    }
    $result = [];
    foreach($filters as $filter) {
      if(empty($filter)) {
        continue;
      }
      $operator = $this->getOperator($filter);
      $value = $this->getFilterValue($filter, $operator);
      $keys = $this->getFilterRelations($filter, $operator);
      $relationTree = $this->createRelationTree($keys, $operator, $value);
      array_push($result, $relationTree);
    }

  return $result;
}
...
Enter fullscreen mode Exit fullscreen mode

Adding filter to query builder

Now that we have all the filters into an array, we need to add these relations to our query builder.

# app/Services/FilterQueryBuilder.php
...
private function addFiltersToQuery($query, $filters) {
    if(count($filters) === 3) {
      switch($filters['operator']) {
      case '()':
        return $query->whereIn($filters['field'], explode(',', $filters['value']));
      case '~':
        return $query->where($filters['field'], 'LIKE', '%' . $filters['value'] . '%');
      default:
        return $query->where($filters['field'], $filters['operator'], $filters['value']);
      }
    }
    $relation = array_key_first($filters);
    return $query->whereHas($relation, function(Builder $query) use($relation, $filters) {
      $this->addFiltersToQuery($query, $filters[$relation]);
    });
}
...
Enter fullscreen mode Exit fullscreen mode

Here is a recursive method, which will iterate over the filters array, if it’s a relation then it would use whereHas which tells the eloquent builder to join over the tables based on the model definition.

Moreover, when it gets to the end of the array, it will add the field and value with given operator into it. You could see we have handled the custom operators as well.

You could also read more about the Eloquent and laravel query builder.

Service Provider

Service providers are the central place of all Laravel application bootstrapping. Your own application, as well as all of Laravel’s core services, are bootstrapped via service providers.

You could read more about the service provider idea here.

The service provider will help you inject your service with laravel service container into your application. It would take care of creating the instance and pass it to your classes and methods.

You need to run the command below to create a service provider called FilterServiceProvider .

$ php artisan make:provider FilterServiceProvider
Enter fullscreen mode Exit fullscreen mode

Then take a look at the file below.

# app/Providers/FilterServiceProvider.php

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class FilterServiceProvider extends ServiceProvider
{
    /** 
     * Bootstrap the application services.
     *
     * [@return](http://twitter.com/return) void
     */
    public function boot()
    {   
        //
    }

/** 
     * Register the application services.
     *
     * [@return](http://twitter.com/return) void
     */
    public function register()
    {   
       //
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let’s say how we are making an instance from our FilterQueryBuilder service inside the register method.

# app/Providers/FilterServiceProvider.php
...
    public function register()
    {
      $this->app->bind(\App\Services\FilterQueryBuilder::class, function () {
        $request = app(\Illuminate\Http\Request::class);

         return new FilterQueryBuilder($request);
      });
}
...
Enter fullscreen mode Exit fullscreen mode

So whenever in the application, you type hinted the FilterQueryBuilder class, it will pass the \Illuminate\Http\Request object into the constructor and give you an instance.

After you have added your service provider, you need to register it so laravel would understand it. Now open the config/app.php file, and under the providers key, add your provider as well.

# config/app.php
...
'providers' => [
   ...
       App\Providers\FilterServiceProvider::class,
   ...

]

...
Enter fullscreen mode Exit fullscreen mode

How to use it now?

So far, so good. Now you could take a look at the article index API code under app/Http/Controllers/Api/ArticleController.php

# app/Http/Controllers/Api/ArticleController.php
...
    public function index(ArticleFilter $filter)
    {
        $articles = new Paginate(Article::loadRelations()->filter($filter));

        return $this->respondWithPagination($articles);
    }

...
Enter fullscreen mode Exit fullscreen mode

However they have created an ArticleFilter class, but if you take a look at the code, it would just handle some given relation. We are going to replace it with our FilterQueryBuilder which would accept any filter on our model based on the model definition. So in future we just need to update our model and the filters would work on them as well.

# app/Http/Controllers/Api/ArticleController.php

...
public function index(\App\Services\FilterQueryBuilder $filters)
{

$articles = $filters->buildQuery(Article::loadRelations());
$result = new Paginate($articles);

return $this->respondWithPagination($result);
}
...

Enter fullscreen mode Exit fullscreen mode




Be Careful !

You should know that this was just a practice and it’s not optimized for large scale applications and also needs to handle so many other things as well. Feel free to communicate and extend it if you like.

Top comments (1)

Collapse
 
mrajaeim profile image
mrajaeim

Awesome thanks!