app/Helpers/QueryHelper.php
<?php

namespace App\Helpers;

use Exception;
use Filter;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use RuntimeException;

class QueryHelper
{
    private const RESERVED_REQUEST_KEYWORDS = [
        'with',
        'withCount',
        'paginate',
        'perPage',
        'page',
        'with_deleted',
        'search',
    ];

    /**
     * @throws Exception
     */
    public static function apply(Builder $query, Model $model, array $filter = [], bool $first = true): void
    {
        $table = $model->getTable();
        $relations = [];

        if (isset($filter['limit'])) {
            $query->limit((int)$filter['limit']);
            unset($filter['limit']);
        }

        if (isset($filter['offset'])) {
            $query->offset((int)$filter['offset']);
            unset($filter['offset']);
        }

        if (isset($filter['orderBy'])) {
            $order_by = $filter['orderBy'];
            [$column, $dir] = isset($order_by[1]) ? array_values($order_by) : [$order_by[0], 'asc'];
            if (Schema::hasColumn($table, $column)) {
                $query->orderBy($column, $dir);
            }

            unset($filter['orderBy']);
        }

        if (isset($filter['with'])) {
            $query->with(self::getRelationsFilter($filter['with']));
        }

        if (isset($filter['withCount'])) {
            $query->withCount($filter['withCount']);
        }

        if (isset($filter['withSum'])) {
            foreach ($filter['withSum'] as $withSum) {
                $query->withSum(...$withSum);
            }
        }

        if (isset($filter['search']['query'], $filter['search']['fields'])) {
            $query = self::buildSearchQuery($query, $filter['search']['query'], $filter['search']['fields']);
        }

        if (isset($filter['where'])) {
            foreach ($filter['where'] as $key => $param) {
                if (str_contains($key, '.')) {
                    $params = explode('.', $key);
                    $domain = array_shift($params);
                    $filterParam = implode('.', $params);

                    if (!isset($relations[$domain])) {
                        $relations[$domain] = [];
                    }

                    $relations[$domain][$filterParam] = $param;
                } elseif (Schema::hasColumn($table, $key) &&
                    !in_array($key, static::RESERVED_REQUEST_KEYWORDS, true) &&
                    !in_array($key, $model->getHidden(), true)
                ) {
                    [$operator, $value] = is_array($param) ? array_values($param) : ['=', $param];

                    if (is_array($value) && $operator !== 'in') {
                        if ($operator === '=') {
                            $query->whereIn("$table.$key", $value);
                        } elseif ($operator === 'between' && count($value) >= 2) {
                            $query->whereBetween("$table.$key", [$value[0], $value[1]]);
                        }
                    } elseif ($operator === 'in') {
                        $inArgs = is_array($value) ? $value : [$value];
                        $query->whereIn("$table.$key", $inArgs);
                    } else {
                        $query->where("$table.$key", $operator, $value);
                    }
                }
            }
        }

        if (!empty($relations)) {
            foreach ($relations as $domain => $filters) {
                if (!method_exists($model, $domain)) {
                    $cls = get_class($model);
                    throw new RuntimeException("Unknown relation $cls::$domain()");
                }

                /** @var Relation $relationQuery */
                $relationQuery = $model->{$domain}();

                $query->whereHas($domain, static function ($q) use ($filters, $relationQuery, $first) {
                    QueryHelper::apply($q, $relationQuery->getModel(), ['where' => $filters], $first);
                });
            }
        }
    }

    /**
     * @throws Exception
     */
    private static function getRelationsFilter(array $filter): array
    {
        $key = array_search('can', $filter, true);
        if ($key !== false) {
            array_splice($filter, $key, 1);

            Filter::listen(Filter::getActionFilterName(), static function ($data) {
                if ($data instanceof Model) {
                    $data->append('can');
                    return $data;
                }

                if ($data instanceof Collection) {
                    return $data->map(static fn(Model $el) => $el->append('can'));
                }

                if ($data instanceof AbstractPaginator) {
                    $data->setCollection($data->getCollection()->map(static fn(Model $el) => $el->append('can')));
                    return $data;
                }

                return $data;
            });
        }

        return $filter;
    }

    /**
     * @param Builder $query
     * @param string $search
     * @param string[] $fields
     *
     * @return Builder
     */
    private static function buildSearchQuery(Builder $query, string $search, array $fields): Builder
    {
        $value = "%$search%";

        return $query->where(static function ($query) use ($value, $fields) {
            $field = array_shift($fields);
            if (str_contains($field, '.')) {
                [$relation, $relationField] = explode('.', $field);
                $query->whereHas($relation, static fn(Builder $query) => $query->where($relationField, 'like', $value)
                );
            } else {
                $query->where($field, 'like', $value);
            }

            foreach ($fields as $field) {
                if (str_contains($field, '.')) {
                    [$relation, $relationField] = explode('.', $field);
                    $query->orWhereHas($relation, static fn(Builder $query) => $query->where($relationField, 'like', $value)
                    );
                } else {
                    $query->orWhere($field, 'like', $value);
                }
            }
        });
    }

    public static function getValidationRules(): array
    {
        return [
            'limit' => 'sometimes|int',
            'offset' => 'sometimes|int',
            'orderBy' => 'sometimes|array',
            'with.*' => 'sometimes|string',
            'withCount.*' => 'sometimes|string',
            'withSum.*.*' => 'sometimes|string',
            'search.query' => 'sometimes|string|nullable',
            'search.fields.*' => 'sometimes|string',
            'where' => 'sometimes|array',
        ];
    }
}
