teoprayoga / laravel-teorion
Request-driven query filter package for Laravel — formalized ScopeFilterTrait pattern with whitelist, isolated scope params, and single-call ViewModel support.
Requires
- php: ^8.1
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
This package is auto-updated.
Last update: 2026-06-03 01:52:02 UTC
README
Request-driven query filter package for Laravel — a formalized, secure replacement for ad-hoc query scope traits, with whitelist enforcement, isolated scope parameters, and single-call ViewModel integration.
Features
- 🎯 Declarative QueryFilter classes — one per resource, central whitelist for filters/scopes/withs/sorts
- 🔒 Whitelist enforcement — no accidental scope/relation exposure to clients
- 🔐 Isolated scope params —
scopes[0][params][role_id]=3keeps params scoped without colliding with global request - 🔌 Built-in filter types — Exact, Like, MultiLike, Boolean, Null, Enum, In, Date, DateRange, Between, Range, GreaterThan, LessThan, Has, JsonContains, Callback, Scope
- 🔁 Backward compatible — supports legacy
scopes[]=nameformat alongside the new isolated-params format - 📑 Dual sort format — Spatie-style
?sort=-col,col2AND legacy?order_by=...&order_direction=... - 🗑️ Auto soft delete handling —
?with_trashed=1,?only_trashed=1work automatically on SoftDeletes models - 📊 Aggregation support —
withCount,withSum,withAvg,withMax,withMinviawithAggregates[] - 🛠 Fluent filter API —
.alias(),.default(),.required()chainable - 🧰 Macro system — register custom global filter types via
FilterMacroRegistry - 📖 Scribe integration — auto-generate API docs from
#[UsesQueryFilter]attribute - ✅ Validation rule generator —
HasQueryFilterRulestrait auto-generates FormRequest rules
Requirements
- PHP 8.1+
- Laravel 10, 11, 12, or 13
| PHP Version | Laravel 10 | Laravel 11 | Laravel 12 | Laravel 13 |
|---|---|---|---|---|
| 8.1 | ✅ | — | — | — |
| 8.2 | ✅ | ✅ | ✅ | — |
| 8.3 | ✅ | ✅ | ✅ | ✅ |
| 8.4 | — | ✅ | ✅ | ✅ |
Installation
composer require teoprayoga/laravel-teorion
(Service provider auto-discovers via Laravel package discovery.)
Quick Start
1. Generate a QueryFilter class
php artisan make:query-filter PostQueryFilter
Creates app/QueryFilters/PostQueryFilter.php.
2. Declare your filters
use Teoprayoga\Teorion\Filters\Filter; use Teoprayoga\Teorion\QueryFilter; class PostQueryFilter extends QueryFilter { protected array $defaultSort = ['-created_at']; public function filters(): array { return [ 'search' => Filter::multiLike(['title', 'description']), 'status' => Filter::enum('status', StatusEnum::class), 'is_active' => Filter::boolean()->default(true), 'created_by' => Filter::exact(), 'has_image' => Filter::has('image'), ]; } public function allowedScopes(): array { return ['published', 'popular']; } public function allowedWiths(): array { return ['author', 'comments', 'tags']; } public function allowedWithCounts(): array { return ['comments', 'reactions']; } public function allowedSorts(): array { return ['created_at', 'title', 'view_count']; } }
3. Add the Filterable trait to your model
Three ways to bind a QueryFilter (pick one):
A. Convention (zero boilerplate) — model Post auto-resolves to App\QueryFilters\PostQueryFilter:
use Teoprayoga\Teorion\Traits\Filterable; class Post extends Model { use Filterable; // Existing scopeXxx() methods stay here — whitelist controls which are exposed. }
B. Property override — explicit, IDE-navigable:
class Post extends Model { use Filterable; protected string $queryFilter = CustomPostQueryFilter::class; }
C. Method override — for dynamic resolution:
use Teoprayoga\Teorion\QueryFilter; class Post extends Model { use Filterable; public function newQueryFilter(): QueryFilter { return new PostQueryFilter(); } }
Customize the convention namespace in config/teorion.php:
'query_filters_namespace' => 'App\\Filters\\Query',
4. Use in your ViewModel/Controller
public function index(GetRequest $request): mixed { return Post::query()->filterAndPaginate($request); // ^^^^^^^^^^^^^^^^^^^^^^^^ // applies all filters, scopes, withs, sorts, // and terminates with paginate() or get() } public function show(GetRequest $request, string $uuid): mixed { return Post::findFiltered($request, $uuid); }
Request Format
| Param | Example | Effect |
|---|---|---|
| Declared filter | ?search=lorem&status=published |
Each declared filter applied if param present |
scopes[] legacy |
?scopes[]=published |
Calls scopePublished($request) with full request |
scopes[N] new |
?scopes[0][name]=forStudent&scopes[0][params][role_id]=3 |
Calls scopeForStudent($scopedRequest) with isolated params |
withs[] |
?withs[]=author&withs[]=comments |
Eager loads (whitelist enforced) |
withCounts[] |
?withCounts[]=comments |
Count loads (whitelist enforced) |
withAggregates |
?withAggregates[comments][sum][]=score |
Sum/avg/max/min aggregates |
sort |
?sort=-created_at,title |
Spatie-style sort, multi-column |
order_by / order_direction |
?order_by=created_at&order_direction=desc |
Legacy single-sort |
is_paginate |
?is_paginate=1&per_page=20 |
Paginate vs get |
with_trashed |
?with_trashed=1 |
Auto-detected on SoftDeletes models |
only_trashed |
?only_trashed=1 |
Soft-deleted only |
visibles[] / hiddens[] |
?hiddens[]=password |
makeVisible / makeHidden on result |
Available Filter Types
| Filter | SQL |
|---|---|
ExactFilter |
WHERE col = ? |
LikeFilter |
WHERE col LIKE %?% |
MultiLikeFilter |
WHERE (col1 LIKE %?% OR col2 LIKE %?%) |
BooleanFilter |
WHERE col = 1/0 |
NullFilter |
WHERE col IS NULL / IS NOT NULL |
EnumFilter |
WHERE col = Enum::from(value)->value |
InFilter |
WHERE col IN (?, ?, ...) |
DateFilter |
WHERE DATE(col) = ? |
DateRangeFilter |
WHERE col BETWEEN ? AND ? |
BetweenFilter |
WHERE col BETWEEN ? AND ? (from single param value array) |
RangeFilter |
WHERE col >= ? AND col <= ? (from _min/_max) |
GreaterThanFilter |
WHERE col > ? (or >=) |
LessThanFilter |
WHERE col < ? (or <=) |
HasFilter |
WHERE EXISTS (relation) / NOT EXISTS |
JsonContainsFilter |
WHERE JSON_CONTAINS(col, ?) |
CallbackFilter |
Inline closure |
ScopeFilter |
Delegates to existing scopeXxx() on model |
Fluent API
'search' => Filter::multiLike(['name', 'desc'])->alias('q'), 'is_active' => Filter::boolean()->default(true), 'tenant_id' => Filter::exact()->required(),
Macros (Custom Filter Types)
// AppServiceProvider::boot() FilterMacroRegistry::register('phone', function ($q, $value, $param) { return $q->where($param, preg_replace('/\D/', '', $value)); }); // In QueryFilter 'phone_number' => Filter::macro('phone'),
Scribe Integration
// Controller use Teoprayoga\Teorion\Attributes\UsesQueryFilter; class PostController { #[UsesQueryFilter(PostQueryFilter::class)] public function index(GetRequest $request): JsonResponse { ... } }
Register the strategy in config/scribe.php:
'strategies' => [ 'queryParameters' => [ Strategies\QueryParameters\GetFromInlineValidator::class, Strategies\QueryParameters\GetFromQueryParamTag::class, \Teoprayoga\Teorion\Scribe\Strategies\UsesQueryFilterStrategy::class, ], ],
Validation Rule Generator
use Teoprayoga\Teorion\Concerns\HasQueryFilterRules; class GetRequest extends FormRequest { use HasQueryFilterRules; protected string $queryFilter = PostQueryFilter::class; public function rules(): array { return array_merge($this->queryFilterRules(), [ // your custom rules ]); } }
Configuration
config/teorion.php (published via php artisan vendor:publish --tag=teorion-config):
return [ 'default_per_page' => 10, 'paginate_key' => 'is_paginate', 'per_page_key' => 'per_page', 'max_results_key' => 'max_results', 'query_filters_namespace' => 'App\\QueryFilters', 'strict_mode' => env('APP_DEBUG', false), ];
strict_mode=true→ throwsDisallowedScopeException/DisallowedWithException/ScopeMethodNotFoundExceptionon unlisted requestsstrict_mode=false→ silently skips disallowed values (production-safe default)
Testing
composer test
License
MIT