DEV Community

Cover image for Stop Writing CRUD Controllers
Jefferson Silva
Jefferson Silva

Posted on

Stop Writing CRUD Controllers

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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),
    ],
];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
    ],
];
Enter fullscreen mode Exit fullscreen mode

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'],
])
Enter fullscreen mode Exit fullscreen mode

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'],
])
Enter fullscreen mode Exit fullscreen mode
GET /query?model=App\Models\User
    &filter[posts.title][like]=%Laravel%
    &filter[posts.comments.author.name][eq]=John
Enter fullscreen mode Exit fullscreen mode

🔒 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)
)
Enter fullscreen mode Exit fullscreen mode

Integrate with Laravel Policies:

->policy('view') // Uses your existing policy
// or
->authorize(fn($request, $model) => 
    $request->user()->isAdmin() || 
    $model->user_id === $request->user()->id
)
Enter fullscreen mode Exit fullscreen mode

⚡ Smart Caching

Cache responses automatically with intelligent invalidation:

->cache(60, 'users-index')
Enter fullscreen mode Exit fullscreen mode

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}%"]
        ),
])
Enter fullscreen mode Exit fullscreen mode

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()
        )
    )
)
Enter fullscreen mode Exit fullscreen mode

Endpoints:

POST   /query?model=App\Models\User          # Create
PATCH  /query/{id}?model=App\Models\User     # Update
DELETE /query/{id}?model=App\Models\User     # Delete
Enter fullscreen mode Exit fullscreen mode

🎯 Flexible Pagination

Choose what fits your use case:

->paginationMode('cursor')  // For infinite scroll
->paginationMode('classic') // Standard page-based (default)
->paginationMode('none')    // Return everything
Enter fullscreen mode Exit fullscreen mode

📊 Column Selection

Reduce payload size by selecting only needed fields:

->select(['id', 'name', 'email', 'posts.title'])
Enter fullscreen mode Exit fullscreen mode

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()
    ),
Enter fullscreen mode Exit fullscreen mode

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),
Enter fullscreen mode Exit fullscreen mode

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'),
Enter fullscreen mode Exit fullscreen mode

Installation

composer require behindsolution/laravel-query-gate
Enter fullscreen mode Exit fullscreen mode

Optionally publish the config:

php artisan vendor:publish --tag=query-gate-config
Enter fullscreen mode Exit fullscreen mode

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

  1. Always authenticate: Use ->middleware(['auth:sanctum'])
  2. Whitelist explicitly: Only expose models you intend to
  3. Restrict operators: Don't allow all operators on all fields
  4. Validate inputs: Use ->filters() with validation rules
  5. Apply rate limiting: Protect against abuse
  6. Scope by tenant: Use ->query() for multi-tenant isolation
  7. 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!
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

Query Gate delegates to Laravel's Eloquent, so standard optimization practices apply:

  1. Use eager loading in ->query() to avoid N+1:
   ->query(fn($q) => $q->with(['posts', 'comments']))
Enter fullscreen mode Exit fullscreen mode
  1. Add database indexes for filtered/sorted columns

  2. Enable caching for expensive queries:

   ->cache(3600)
Enter fullscreen mode Exit fullscreen mode
  1. Limit pagination size:
   // config/query-gate.php
   'pagination' => [
       'per_page' => 25,
       'max_per_page' => 100,
   ],
Enter fullscreen mode Exit fullscreen mode
  1. Use cursor pagination for large datasets:
   ->paginationMode('cursor')
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
xwero profile image
david duymelinck

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.

Collapse
 
jeffsynister profile image
Jefferson Silva • Edited

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.