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.
Track planned feature waves in GitHub Projects:
- PHP:
8.3,8.4,8.5 - Laravel:
12.x,13.x
composer require zaruto/queryablePublish config (optional):
php artisan vendor:publish --tag="queryable-config"<?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;
}<?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);
}
}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
scopeSearch(Builder $query, ?string $search): BuilderscopeFilter(Builder $query): BuilderapplyFilters(Builder $query, Request $request): BuilderscopeSort(Builder $query, ?string $sortBy = null, string $direction = 'asc'): Builder
config/queryable.php:
return [
'strict_mode' => true,
'parameters' => [
'search' => 'search',
'filter' => 'filter',
'sort_by' => 'sort_by',
'direction' => 'direction',
],
];strict_mode=truevalidates filter fields/operators against your allowlist.- If you rename parameter keys, use the same keys in your clients.
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.
<?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'];
}
}Queryable uses a token parser and supports grouped boolean expressions.
condition := field operator value
group := (expression)
expression := condition (and|or condition|group)*
| 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 |
andor- Parentheses
(...)
Example:
filter=(status eq active and score gte 50) or team.name like "Ops%"
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
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.
- 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
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))
<?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);
}
}composer test
composer analyse
composer formatCI workflow coverage:
- tests (
.github/workflows/tests.yml) - lint (
.github/workflows/lint.yml) - static analysis (
.github/workflows/static-analysis.yml)
Run this before creating any new release tag:
composer run release:gateThis 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:matrixThis 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.
- Custom operator registration.
- Relation-aware sorting.
- Multi-column sort expressions.
- Request helper/pipeline utilities.
MIT