zaruto / queryable
Attribute-first query composition for Laravel Eloquent models.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^12.0||^13.0
- illuminate/database: ^12.0||^13.0
- illuminate/http: ^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
This package is auto-updated.
Last update: 2026-05-05 12:41:39 UTC
README
Attribute-first, type-safe query composition for Laravel Eloquent models.
zaruto/queryable helps API teams safely expose search, filter, and sort query params without allowing arbitrary field/operator access.
Roadmap
Track planned feature waves in GitHub Projects:
Compatibility
- PHP:
8.3,8.4,8.5 - Laravel:
12.x,13.x
Installation
composer require zaruto/queryable
Publish config (optional):
php artisan vendor:publish --tag="queryable-config"
Quickstart (Under 5 Minutes)
1. Add traits + attributes to a model
<?php declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Model; use Zaruto\Queryable\Attributes\QueryableFilterable; use Zaruto\Queryable\Attributes\QueryableSearchable; use Zaruto\Queryable\Attributes\QueryableSortable; use Zaruto\Queryable\Concerns\Filterable; use Zaruto\Queryable\Concerns\Searchable; use Zaruto\Queryable\Concerns\Sortable; #[QueryableSearchable([ 'name' => 'like', 'email' => 'starts_with', 'team.name' => 'like', ])] #[QueryableFilterable([ 'status' => ['eq', 'ne', 'in', 'not in'], 'score' => ['gt', 'gte', 'lt', 'lte'], 'name' => ['like', 'contains', 'starts_with'], 'team.name' => ['eq', 'like'], ])] #[QueryableSortable(['id', 'name', 'created_at'])] class Customer extends Model { use Searchable; use Filterable; use Sortable; }
2. Chain query scopes in controller/repository
<?php declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Customer; use Illuminate\Http\Request; final class CustomerIndexController { public function __invoke(Request $request) { $query = Customer::query() ->search((string) $request->query('search', '')) ->filter() ->sort( $request->query('sort_by'), (string) $request->query('direction', 'asc') ); return $query->paginate(25); } }
3. Call endpoint with query params
GET /api/customers?search=ali
GET /api/customers?filter=status%20eq%20active
GET /api/customers?sort_by=created_at&direction=desc
GET /api/customers?search=ali&filter=score%20gte%2050&sort_by=name
Public API (Stable Surface)
scopeSearch(Builder $query, ?string $search): BuilderscopeFilter(Builder $query): BuilderapplyFilters(Builder $query, Request $request): BuilderscopeSort(Builder $query, ?string $sortBy = null, string $direction = 'asc'): Builder
Configuration
config/queryable.php:
return [ 'strict_mode' => true, 'parameters' => [ 'search' => 'search', 'filter' => 'filter', 'sort_by' => 'sort_by', 'direction' => 'direction', ], ];
Notes
strict_mode=truevalidates filter fields/operators against your allowlist.- If you rename parameter keys, use the same keys in your clients.
Attribute-First With Method Fallback
The package resolves model config in this order:
- Attributes (
#[QueryableSearchable],#[QueryableFilterable],#[QueryableSortable]) - Static methods (
searchable(),filters(),sortable())
Use method fallback when you need dynamic configuration or gradual migration.
Method fallback example
<?php declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Model; use Zaruto\Queryable\Concerns\Filterable; use Zaruto\Queryable\Concerns\Searchable; use Zaruto\Queryable\Concerns\Sortable; class Order extends Model { use Searchable; use Filterable; use Sortable; public static function searchable(): array { return [ 'number' => 'starts_with', 'customer.name' => 'like', ]; } public static function filters(): array { return [ 'status' => ['eq', 'ne', 'in'], 'total' => ['gt', 'gte', 'lt', 'lte'], 'customer.name' => ['like'], ]; } public static function sortable(): array { return ['id', 'number', 'created_at']; } }
Filter Grammar Reference
Queryable uses a token parser and supports grouped boolean expressions.
Grammar shape
condition := field operator value
group := (expression)
expression := condition (and|or condition|group)*
Supported operators
| Operator | Meaning | Example filter snippet |
|---|---|---|
eq |
equals | status eq active |
ne |
not equals | status ne archived |
gt |
greater than | score gt 80 |
gte |
greater or equal | score gte 80 |
lt |
lower than | score lt 80 |
lte |
lower or equal | score lte 80 |
like |
raw SQL like value |
name like ali% |
contains |
%value% |
name contains ali |
starts_with |
value% |
email starts_with admin |
in |
in comma list | status in active,pending |
not in |
not in comma list | status not in blocked,deleted |
Boolean and grouping
andor- Parentheses
(...)
Example:
filter=(status eq active and score gte 50) or team.name like "Ops%"
Query URL Examples
GET /api/customers?filter=status%20eq%20activeGET /api/customers?filter=score%20gte%2050%20and%20score%20lt%2090GET /api/customers?filter=team.name%20like%20Ops%25GET /api/customers?filter=status%20in%20active,pendingGET /api/customers?filter=(status%20eq%20active%20or%20status%20eq%20pending)%20and%20score%20gt%2060
Relation Fields
Use dot notation for relation fields:
- Search:
team.name - Filter:
team.name
Current behavior:
- Filter relation handling splits on first dot (
relation.field). - Search supports nested relation path style via dot notation keys.
Sorting Behavior
- Only allowlisted fields are sortable.
- Direction normalization:
descstaysdesc- any other value becomes
asc
Examples:
GET /api/customers?sort_by=name&direction=ascGET /api/customers?sort_by=created_at&direction=descGET /api/customers?sort_by=id&direction=INVALID-> usesasc
Strict Mode and Errors
When strict_mode=true:
- Unknown filter field throws
InvalidFilterException. - Disallowed operator for an allowed field throws
InvalidFilterException. - Invalid/incomplete syntax throws
InvalidFilterExceptionfrom parser.
Example invalid requests:
filter=unknown eq 1filter=status between active,pending(unsupported operator)filter=(status eq active(missing closing))
End-to-End Example
<?php declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Customer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; final class CustomerController extends Controller { public function index(Request $request): JsonResponse { $customers = Customer::query() ->search((string) $request->query('search', '')) ->filter() ->sort( $request->query('sort_by'), (string) $request->query('direction', 'asc') ) ->paginate((int) $request->query('per_page', 25)); return response()->json($customers); } }
Testing and Development
composer test
composer analyse
composer format
CI workflow coverage:
- tests (
.github/workflows/tests.yml) - lint (
.github/workflows/lint.yml) - static analysis (
.github/workflows/static-analysis.yml)
Pre-Tag Release Gate
Run this before creating any new release tag:
composer run release:gate
This runs, in order:
composer install./vendor/bin/pint --test./vendor/bin/phpstan analyse --error-format=table./vendor/bin/pest --ci
For additional Laravel matrix spot-checks (recommended for release candidates/finals):
composer run release:gate:matrix
This additionally runs sequential checks for:
- Laravel
12.*+ Testbench^10.0 - Laravel
13.*+ Testbench^11.0
Tagging rule: only create/push a tag if the gate passes and the working tree is clean.
Roadmap (Concise)
- Custom operator registration.
- Relation-aware sorting.
- Multi-column sort expressions.
- Request helper/pipeline utilities.
License
MIT