mitoop / laravel-query-builder
Declarative Eloquent filters for Laravel.
Requires
- php: ^8.2
- laravel/framework: ^11|^12|^13
Requires (Dev)
- laravel/pint: ^1.30
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.5|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel Query Builder 是一个基于接口设计、具备高扩展性的渐进式搜索构建包,旨在让 Laravel 中的复杂搜索变得更简单、更清晰、更优雅。
它将搜索逻辑从控制器中彻底解耦,使列表搜索功能的开发更加集中、可维护,同时支持高度复用和灵活扩展。rules() 里声明条件,调用后仍是原来的 Eloquent 查询,可以继续 with()、paginate()、get()。
本包的设计灵感来源于 zhuzhichao/laravel-advanced-search 和 spatie/laravel-query-builder,并结合实际业务需求进行了增强与重构。
环境要求
- PHP ^8.2
- Laravel ^11.0 | ^12.0 | ^13.0
安装
composer require mitoop/laravel-query-builder
定义 Filter
php artisan make:filter UserFilter
命令会在 App\Http\Filters 下生成类:
<?php namespace App\Http\Filters; use Mitoop\LaravelQueryBuilder\AbstractFilter; class UserFilter extends AbstractFilter { protected array $allowedSorts = ['id']; protected function rules(): array { return []; } }
控制器里:
use App\Http\Filters\UserFilter; use App\Models\User; $users = User::filter(UserFilter::class)->paginate();
不传第二个参数,或传入空数组时,使用当前请求的全部输入。要换成别的数据时传进去:
User::filter(UserFilter::class, $request->validated())->paginate();
规则也可以直接写成数组,不必先建类:
User::filter([ 'name', 'status|in', ])->paginate();
值为 null、空字符串或空数组的规则会跳过。
搜索规则
规则写在 rules() 里。字符串的格式是:
[请求字段]:[数据库字段]|[操作符]
冒号和竖线都可以省略。省略冒号时,请求字段和数据库字段同名。省略操作符时,用 eq。
use Mitoop\LaravelQueryBuilder\ValueResolvers\DateRange; use Mitoop\LaravelQueryBuilder\ValueResolvers\Like; protected function rules(): array { return [ 'name', 'email|like' => new Like, 'created_from:created_at|gte', 'created_to:created_at|lte', 'created_at|between' => new DateRange, 'nickname:profile->nickname|like' => new Like, 'position$name|like' => new Like, 'u.name', ]; }
| 写法 | 含义 |
|---|---|
name |
请求里的 name 等于 name 列 |
email|like |
用 like 比较 email |
created_from:created_at|gte |
请求字段是 created_from,列是 created_at |
profile->nickname |
JSON 路径 |
position$name |
position 关联上的 name 列 |
u.name |
带表别名的列 |
position$name 会变成 whereHas('position', ...)。关联名会转成驼峰,user_profile$bio 对应 userProfile。
操作符
| 操作符 | 作用 |
|---|---|
eq |
=,默认 |
ne |
<> |
gt / lt |
> / < |
gte / ge |
>= |
lte / le |
<= |
like |
like ? escape ?。通配符由值处理器加上,操作符补上 ESCAPE '\' |
in / not_in |
数组,或 1,2 这种逗号分隔的字符串 |
between |
正好两个值,数组或 start,end。数量不对会抛异常 |
is_null / not_null |
请求值为真时才加上 whereNull / whereNotNull |
json_contains |
JSON 包含 |
数据库不认识的操作符会抛出 InvalidArgumentException。
自定义操作符在 AppServiceProvider::boot() 里注册:
use Illuminate\Database\Eloquent\Builder; use Mitoop\LaravelQueryBuilder\Contracts\OperatorFactoryInterface; use Mitoop\LaravelQueryBuilder\Contracts\OperatorInterface; class BeforeOperator implements OperatorInterface { public function apply(Builder $builder, string $whereType, string $field, $value): void { $builder->{$whereType}($field, '<', $value); } } app(OperatorFactoryInterface::class)->register('before', fn () => new BeforeOperator);
注册名就是规则里的操作符,例如 published_at|before。
值处理
操作符只决定比较方式。%、日期边界这类转换交给值处理器。
Mitoop\LaravelQueryBuilder\ValueResolvers\Like:调用LikeClause::pattern(),两侧加上%,并转义输入里的%、_和\Mitoop\LaravelQueryBuilder\ValueResolvers\LikeAny:同一套LikeClause::pattern(),匹配多个列Mitoop\LaravelQueryBuilder\LikeColumn:同一个关键字按请求里的类型选一列。类型不在名单里时用默认列Mitoop\LaravelQueryBuilder\ValueResolvers\DateRange:把两个日期转成当天的开始和结束。格式不对时这条规则跳过
use Mitoop\LaravelQueryBuilder\ValueResolvers\DateRange; use Mitoop\LaravelQueryBuilder\ValueResolvers\Like; use Mitoop\LaravelQueryBuilder\ValueResolvers\LikeAny; protected function rules(): array { return [ 'email|like' => new Like, 'keyword|like_any' => new LikeAny(['name', 'email']), 'created_at|between' => new DateRange, ]; }
Like 的前后缀可以改,例如只匹配开头:new Like('', '%')。
同一个关键字要按类型落到不同列时,用 LikeColumn。它直接放进 rules(),一次只查一列。
use Mitoop\LaravelQueryBuilder\LikeColumn; protected function rules(): array { return [ new LikeColumn( field: 'keyword', type: 'keyword_type', columns: ['name', 'email', 'phone'], default: 'name', ), ]; }
| 参数 | 作用 |
|---|---|
field |
关键字所在的请求字段 |
type |
列类型所在的请求字段 |
columns |
允许被选中的列。请求里的类型必须和其中一项完全一致 |
default |
没传类型、类型为空,或类型不在 columns 里时使用的列 |
上面的规则对应这些请求:
| 请求 | 查询 |
|---|---|
keyword=ada |
name like |
keyword=ada&keyword_type=name |
name like。name 在 columns 里,按这个类型查询 |
keyword=ada&keyword_type=email |
只查 email |
keyword=ada&keyword_type=other |
name like。other 不在 columns 里,所以用 default |
字段有值时才追加一段查询,用 whenValue()。手写查询也走 LikeClause。pattern() 转义输入里的 %、_ 和 \。后两个参数是前后缀,省略时两侧都是 %。下面只在后面加 %,输入 ada 时,$pattern 是 ada%。where() 和 orWhere() 加上 ESCAPE。
use Illuminate\Database\Eloquent\Builder; use Mitoop\LaravelQueryBuilder\Support\LikeClause; $this->whenValue('keyword', function (Builder $builder, string $keyword): void { $pattern = LikeClause::pattern($keyword, '', '%'); if ($pattern === '') { return; } LikeClause::where($builder, 'name', $pattern); LikeClause::orWhere($builder, 'email', $pattern); });
要实现自己的值处理器,实现 Mitoop\LaravelQueryBuilder\Contracts\ValueResolver。resolve() 接收请求里的原始值,返回交给操作符使用的值。
use Mitoop\LaravelQueryBuilder\Contracts\ValueResolver; class UpperName implements ValueResolver { public function resolve($value): string { return strtoupper((string) $value); } }
规则写成 'name' => new UpperName 时,等值比较用的就是 resolve() 的返回值。
关联、Scope 和原生条件
rules() 里也可以放 Scope、闭包和 DB::raw()。
use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; use Mitoop\LaravelQueryBuilder\Scope; protected function rules(): array { return [ new Scope('active'), (new Scope('role', (int) $this->value('role_id')))->when((bool) $this->value('role_id')), function (Builder $builder): void { $builder->where('is_verified', true); }, DB::raw('users.score > 100'), ]; }
new Scope('emailVerified') 和 new Scope('email_verified') 都会调用模型上的 scopeEmailVerified。when() 的条件不成立时,这个 Scope 不会加入查询。
Laravel 12 起,模型方法也可以用 #[Scope] 标记。Laravel 11 只有 scopeXxx 这种写法。
排序
请求参数默认是 sorts。字段前加 - 表示降序:
sorts=-id,name
只有 $allowedSorts 里的字段会生效,其余忽略。默认只允许 id。
protected array $allowedSorts = ['id', 'name', 'created_at'];
换参数名时改 $sortField。每个 Filter 用自己的名字:
protected string $sortField = 'order_by';
sorts() 返回非空数组时,不再读取请求里的排序:
protected function sorts(): array { return [ 'id' => 'desc', ]; }
数字下标的值会原样写进 orderByRaw(),例如 'created_at asc'。
预加载
Filter 定义了 with() 时,查询构建前会自动预加载:
protected function with(): array { return ['org', 'roles']; }
with() 需要是 public 或 protected。重写 boot() 时先调用父类,否则上面的预加载不会执行:
public function boot(): void { parent::boot(); }
默认筛选值
booting() 在请求数据写入之后、规则生效之前运行。请求里已经带了 status 时,下面这行不会覆盖它:
public function booting(): void { $this->data['status'] ??= 'active'; }
完整示例
<?php namespace App\Http\Filters; use Illuminate\Database\Eloquent\Builder; use Mitoop\LaravelQueryBuilder\AbstractFilter; use Mitoop\LaravelQueryBuilder\LikeColumn; use Mitoop\LaravelQueryBuilder\Scope; use Mitoop\LaravelQueryBuilder\ValueResolvers\DateRange; use Mitoop\LaravelQueryBuilder\ValueResolvers\Like; use Mitoop\LaravelQueryBuilder\ValueResolvers\LikeAny; class UserFilter extends AbstractFilter { protected array $allowedSorts = ['id', 'created_at']; protected function rules(): array { return [ 'id', 'name|like' => new Like, 'email|like' => new Like, 'status|in', 'created_from:created_at|gte', 'created_to:created_at|lte', 'created_at|between' => new DateRange, 'nickname:profile->nickname|like' => new Like, 'tag:profile->tags|json_contains', 'position$name|like' => new Like, 'q|like_any' => new LikeAny(['title', 'company']), new LikeColumn( field: 'keyword', type: 'keyword_type', columns: ['name', 'email', 'phone'], default: 'name', ), new Scope('active'), $this->whenValue('role_id', function (Builder $builder, int|string $roleId): void { $builder->where('role_id', (int) $roleId); }), ]; } protected function with(): array { return ['position']; } }
User::filter(UserFilter::class)->paginate();
贡献
欢迎提交 issue 或 Pull Request。
协议
MIT