Building APIs shouldn't mean writing the same CRUD controllers over and over. Laravel Query Gate turns your Eloquent models into powerful, secure APIs through simple configuration.
The Problem
How many times have you written something like this?
class UserController extends Controller
{
public function index(Request $request)
{
$query = User::query();
// Filter by email
if ($request->has('email')) {
$query->where('email', 'like', '%' . $request->email . '%');
}
// Filter by creation date
if ($request->has('created_after')) {
$query->where('created_at', '>=', $request->created_after);
}
// Filter by status
if ($request->has('status')) {
$query->where('status', $request->status);
}
// Sort
$sortBy = $request->get('sort_by', 'created_at');
$sortOrder = $request->get('sort_order', 'desc');
$query->orderBy($sortBy, $sortOrder);
// Paginate
return $query->paginate($request->get('per_page', 15));
}
}
Now multiply that by every model in your application. Add validation. Add security checks. Add caching. Add eager loading for relations. The boilerplate grows exponentially.
The Solution
// config/query-gate.php
use BehindSolution\LaravelQueryGate\Support\QueryGate;
return [
'models' => [
App\Models\User::class => QueryGate::make()
->middleware(['auth:sanctum'])
->filters([
'email' => 'email',
'created_at' => 'date',
'status' => 'in:active,inactive,pending',
])
->allowedFilters([
'email' => ['like'],
'created_at' => ['gte', 'lte'],
'status' => ['eq'],
])
->cache(60),
],
];
That's it. Now you have a fully functional, secure API endpoint:
GET /query?model=App\Models\User
&filter[email][like]=%@gmail.com
&filter[created_at][gte]=2024-01-01
&filter[status][eq]=active
&sort=created_at:desc
&per_page=50
Key Features
Alias
Keep the name short.
return [
'model_aliases' => [
'users' => App\Models\User::class,
],
'models' => [
App\Models\User::class => QueryGate::make()
->middleware(['auth:sanctum'])
->filters([
'email' => 'email',
'created_at' => 'date',
'status' => 'in:active,inactive,pending',
])
->allowedFilters([
'email' => ['like'],
'created_at' => ['gte', 'lte'],
'status' => ['eq'],
])
->cache(60),
],
];
Aliases are case-insensitive and map to the underlying fully-qualified model name, so requests can use /query?model=users while the original namespace continues to work.
🔍 Powerful Filtering
Support for multiple operators out of the box:
->filters([
'price' => 'numeric',
'title' => 'string',
'published_at' => 'date',
])
->allowedFilters([
'price' => ['eq', 'lt', 'lte', 'gt', 'gte', 'between'],
'title' => ['like'],
'published_at' => ['eq', 'between'],
])
Available operators: eq, neq, lt, lte, gt, gte, like, in, between
🔗 Relation Filtering with Dot Notation
Filter through relationships seamlessly:
->filters([
'posts.title' => 'string',
'posts.comments.author.name' => 'string',
])
->allowedFilters([
'posts.title' => ['like'],
'posts.comments.author.name' => ['eq'],
])
GET /query?model=App\Models\User
&filter[posts.title][like]=%Laravel%
&filter[posts.comments.author.name][eq]=John
🔒 Security First
Whitelist everything. No surprises.
->middleware(['auth:sanctum', 'throttle:60,1'])
->filters(['email' => 'email']) // Only these fields can be filtered
->allowedFilters(['email' => ['eq']]) // Only these operators allowed
->select(['id', 'name', 'email']) // Only these fields returned
->query(fn($q, $request) =>
$q->where('tenant_id', $request->user()->tenant_id)
)
Integrate with Laravel Policies:
->policy('view') // Uses your existing policy
// or
->authorize(fn($request, $model) =>
$request->user()->isAdmin() ||
$model->user_id === $request->user()->id
)
⚡ Smart Caching
Cache responses automatically with intelligent invalidation:
->cache(60, 'users-index')
The cache key includes:
- Model class
- All filters
- Sort parameters
- Pagination settings
- Authenticated user ID
Cache is automatically cleared after create, update, or delete operations.
🔧 Raw Filters for Complex Cases
Need custom logic? No problem:
->filters(['full_name' => 'string'])
->rawFilters([
'full_name' => fn($builder, $operator, $value, $column) =>
$builder->whereRaw(
"CONCAT(first_name, ' ', last_name) LIKE ?",
["%{$value}%"]
),
])
You keep the validation and whitelisting, but control the query logic.
📝 Optional CRUD Operations
Enable create, update, and delete when needed:
->actions(fn($actions) => $actions
->create(fn($action) => $action
->validations([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
])
->policy('create')
)
->update(fn($action) => $action
->validations([
'name' => 'sometimes|string|max:255',
'email' => 'sometimes|email|unique:users,email',
])
)
->delete(fn($action) => $action
->authorize(fn($req, $model) =>
$req->user()->isAdmin()
)
)
)
Endpoints:
POST /query?model=App\Models\User # Create
PATCH /query/{id}?model=App\Models\User # Update
DELETE /query/{id}?model=App\Models\User # Delete
🎯 Flexible Pagination
Choose what fits your use case:
->paginationMode('cursor') // For infinite scroll
->paginationMode('classic') // Standard page-based (default)
->paginationMode('none') // Return everything
📊 Column Selection
Reduce payload size by selecting only needed fields:
->select(['id', 'name', 'email', 'posts.title'])
Primary and foreign keys are automatically included for proper relation hydration.
Real-World Examples
Multi-tenant SaaS
App\Models\Project::class => QueryGate::make()
->middleware(['auth:sanctum', 'tenant.isolate'])
->query(fn($q, $request) =>
$q->where('tenant_id', $request->user()->tenant_id)
)
->filters([
'status' => 'in:planning,active,completed,archived',
'created_at' => 'date',
'members.user_id' => 'integer',
])
->allowedFilters([
'status' => ['eq', 'in'],
'created_at' => ['between'],
'members.user_id' => ['eq'],
])
->cache(300)
->actions(fn($actions) => $actions
->create(fn($action) => $action
->validations([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
])
->handle(function($request, $model, $payload) {
$model->fill($payload);
$model->tenant_id = $request->user()->tenant_id;
$model->save();
return $model;
})
)
->update()
->delete()
),
E-commerce Product Catalog
App\Models\Product::class => QueryGate::make()
->query(fn($q) => $q
->selectRaw('products.*, AVG(reviews.rating) as avg_rating')
->leftJoin('reviews', 'reviews.product_id', '=', 'products.id')
->groupBy('products.id')
)
->filters([
'category_id' => 'integer',
'price' => 'numeric',
'tags.slug' => 'string',
'in_stock' => 'boolean',
])
->allowedFilters([
'category_id' => ['eq', 'in'],
'price' => ['between', 'lte', 'gte'],
'tags.slug' => ['in'],
'in_stock' => ['eq'],
])
->select(['id', 'name', 'price', 'avg_rating', 'image_url'])
->cache(600),
Analytics Dashboard
App\Models\Order::class => QueryGate::make()
->middleware(['auth:sanctum', 'role:admin'])
->query(fn($q) => $q
->selectRaw('
DATE(created_at) as date,
COUNT(*) as total_orders,
SUM(total) as revenue,
AVG(total) as average_order_value
')
->groupBy('date')
)
->filters([
'created_at' => 'date',
'status' => 'in:pending,completed,cancelled,refunded',
])
->allowedFilters([
'created_at' => ['between', 'gte', 'lte'],
'status' => ['eq', 'in'],
])
->paginationMode('none')
->cache(1800, 'orders-analytics'),
Installation
composer require behindsolution/laravel-query-gate
Optionally publish the config:
php artisan vendor:publish --tag=query-gate-config
When to Use Query Gate
✅ Perfect for:
- Admin dashboards
- Internal tools
- BI/Analytics integrations
- Mobile app backends
- Rapid prototyping
- Reports and data exports
❌ Not ideal for:
- Public APIs with complex business logic
- Operations requiring multi-step workflows
- Cases where you need fine-grained control over every query
- GraphQL (use Laravel Lighthouse instead)
Security Best Practices
-
Always authenticate: Use
->middleware(['auth:sanctum']) - Whitelist explicitly: Only expose models you intend to
- Restrict operators: Don't allow all operators on all fields
-
Validate inputs: Use
->filters()with validation rules - Apply rate limiting: Protect against abuse
-
Scope by tenant: Use
->query()for multi-tenant isolation - Use policies: Integrate with your existing authorization
// ✅ Good
->filters(['email' => 'email'])
->allowedFilters(['email' => ['eq']])
->middleware(['auth:sanctum', 'throttle:60,1'])
// ❌ Bad
->filters(['email' => 'string'])
->allowedFilters(['email' => ['eq', 'neq', 'like', 'in']]) // Too permissive
// No middleware!
Performance Considerations
Query Gate delegates to Laravel's Eloquent, so standard optimization practices apply:
-
Use eager loading in
->query()to avoid N+1:
->query(fn($q) => $q->with(['posts', 'comments']))
Add database indexes for filtered/sorted columns
Enable caching for expensive queries:
->cache(3600)
- Limit pagination size:
// config/query-gate.php
'pagination' => [
'per_page' => 25,
'max_per_page' => 100,
],
- Use cursor pagination for large datasets:
->paginationMode('cursor')
Comparison with Alternatives
| Feature | Query Gate | Custom Controllers | GraphQL | API Platform |
|---|---|---|---|---|
| Setup Time | 5 minutes | Hours | Days | Days |
| Laravel Native | ✅ | ✅ | ❌ | ❌ |
| Learning Curve | Low | None | High | High |
| Flexibility | High | Total | Medium | Medium |
| Auto Validation | ✅ | Manual | ✅ | ✅ |
| Caching | ✅ | Manual | ❌ | ❌ |
FAQ
Q: Does this replace controllers?
A: No. Use Query Gate for standard CRUD + filtering. Create controllers for complex business logic.
Q: Is it production-ready?
A: Yes. It uses Laravel's core components (Eloquent, Validator, Policies, Cache). When configured properly, it's as secure as any Laravel API.
Q: Can I customize the query logic?
A: Absolutely. Use ->query(), ->rawFilters(), ->authorize(), and ->handle() callbacks for full control.
Q: How does it handle relationships?
A: Via dot notation in filters and select. Query Gate automatically handles joins and eager loading.
Q: What about multi-tenancy?
A: Use ->query() to scope by tenant or apply tenant isolation middleware.
Conclusion
Laravel Query Gate solves a specific problem: eliminating boilerplate for standard CRUD operations. It's not trying to replace controllers or be a full GraphQL alternative.
It's a tool that saves you time on the 80% of straightforward cases, while giving you complete escape hatches for the 20% that need custom logic.
Try it in your next Laravel project. Your future self will thank you for not writing another paginated, filtered, sorted CRUD controller.
Links:
Requirements:
- PHP 8.2+
- Laravel 9, 10, 11, or 12
Have you used Query Gate? Share your experience in the comments! Found a bug or have a feature request? Open an issue on GitHub.
Top comments (3)
While it looks like an extensive library, the main problem I have is that it is moving too much information to the url.
While I see there are ways to whitelist models and filters, it exposes models and database fields.
With controller input, the relationship is not one on one. And if it is one on one it is possible to add mapping for extra security.
I understand the convenience of the library, but I don't think it is worth the security impact.
I understand the concern, but I think there is a distinction worth making between exposure, abstraction, and security.
QueryGate only moves query instructions to the URL: model, filters, sorting and pagination. This is not fundamentally different from what many admin tools already do. For example, Laravel Nova sends an even larger query surface (filters, relations, scopes) encoded in base64, which is still observable and logged.
Security-wise, QueryGate does not expose data by default. Filters and operators are explicitly whitelisted, middleware and policies are enforced, and the response payload is fully controlled. In practice, the response can be more restrictive than a typical Laravel Resource by using explicit selects and relation control.
The presence of model or column names in the request does not grant additional access. Access is still defined by authorization, validation, and scoping, exactly as in controller-based APIs.
I agree that this approach introduces tighter coupling and is not ideal for public APIs with long-lived external contracts. QueryGate is intentionally designed for internal, technical-facing APIs where that trade-off is understood and accepted in exchange for reduced boilerplate and faster iteration.
I appreciate the feedback — it helps clarify the intended scope of the library.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.