laravel-query-binding maintained by pxl
Laravel Query Binding
Declarative route model binding with full query builder control.
The Problem
Laravel's route model binding is convenient but inflexible. You lose control over the query when using implicit binding:
Route::get('/users/{user}', function (User $user) {
return $user;
});
Common pain points:
- N+1 queries: No way to eager load relationships in the binding
- Over-fetching: Can't select specific columns
- Soft deletes: Must use
withTrashed()in the controller - Scopes: Can't apply query scopes declaratively
This package solves these problems with a clean, declarative API.
Installation
composer require pxl/laravel-query-binding
The package auto-registers its service provider. No additional configuration required.
Quick Start
use App\Models\User;
Route::get('/users/{user}', fn (User $user) => $user)
->bindWith('user', ['posts', 'comments']);
API Reference
Core Method
bindQuery(string $parameter, callable $callback): Route
The foundation method that all other methods build upon. Accepts a query callback for complete control.
Route::get('/users/{user}', fn (User $user) => $user)
->bindQuery('user', fn ($query) => $query
->select(['id', 'name', 'email'])
->with('profile')
->where('active', true));
Parent Model Access: Query callbacks receive previously resolved models as additional parameters:
Route::get('/users/{user}/posts/{post}', fn (User $user, Post $post) => $post)
->bindQuery('post', fn ($query, User $user) => $query
->where('user_id', $user->id)
->with('tags'));
Convenience Methods
bindWith(string $parameter, array|string $relations): Route
Eager load relationships to prevent N+1 queries.
Route::get('/posts/{post}', fn (Post $post) => $post)
->bindWith('post', ['author', 'tags', 'comments.user']);
Route::get('/users/{user}', fn (User $user) => $user)
->bindWith('user', 'posts');
bindWithCount(string $parameter, array|string $relations): Route
Add relationship counts without loading the relationships.
Route::get('/users/{user}', fn (User $user) => [
'user' => $user,
'posts_count' => $user->posts_count,
])
->bindWithCount('user', ['posts', 'comments']);
bindSelect(string $parameter, array $columns): Route
Select specific columns for optimized queries.
Route::get('/users/{user}', fn (User $user) => $user)
->bindSelect('user', ['id', 'name', 'avatar']);
bindWithTrashed(string $parameter): Route
Include soft-deleted models in the query.
Route::get('/admin/users/{user}', fn (User $user) => $user)
->bindWithTrashed('user');
bindOnlyTrashed(string $parameter): Route
Return only soft-deleted models.
Route::get('/trash/users/{user}', fn (User $user) => $user)
->bindOnlyTrashed('user');
bindScoped(string $parameter, string $scope, mixed ...$args): Route
Apply a named model scope.
Route::get('/posts/{post}', fn (Post $post) => $post)
->bindScoped('post', 'published');
Route::get('/posts/{post}', fn (Post $post) => $post)
->bindScoped('post', 'byCategory', 'technology');
bindWhere(string $parameter, string $column, mixed $operator = null, mixed $value = null): Route
Apply a simple where condition.
Route::get('/users/{user}', fn (User $user) => $user)
->bindWhere('user', 'active', true);
Route::get('/users/{user}', fn (User $user) => $user)
->bindWhere('user', 'role', '!=', 'admin');
bindWithoutGlobalScope(string $parameter, string|array $scopes): Route
Remove specific global scopes.
Route::get('/admin/posts/{post}', fn (Post $post) => $post)
->bindWithoutGlobalScope('post', 'published');
bindWithoutGlobalScopes(string $parameter, ?array $scopes = null): Route
Remove all or specified global scopes.
Route::get('/admin/posts/{post}', fn (Post $post) => $post)
->bindWithoutGlobalScopes('post');
Route::get('/admin/posts/{post}', fn (Post $post) => $post)
->bindWithoutGlobalScopes('post', ['published', 'active']);
Advanced Usage
Custom Route Keys
Works seamlessly with Laravel's custom route key syntax:
Route::get('/users/{user:slug}', fn (User $user) => $user)
->bindWith('user', ['posts']);
Route::get('/posts/{post:uuid}', fn (Post $post) => $post)
->bindQuery('post', fn ($query) => $query->with('author'));
Also respects the model's getRouteKeyName() method:
class User extends Model
{
public function getRouteKeyName(): string
{
return 'slug';
}
}
QueryBindable Interface
Implement QueryBindable on your models to define default binding behavior:
use Pxl\QueryBinding\Contracts\QueryBindable;
use Illuminate\Database\Eloquent\Builder;
class Post extends Model implements QueryBindable
{
public function scopeForRouteBinding(Builder $query): Builder
{
return $query
->with(['author:id,name', 'tags'])
->where('published', true);
}
}
The scopeForRouteBinding is automatically applied, and you can add additional customizations:
Route::get('/posts/{post}', fn (Post $post) => $post)
->bindQuery('post', fn ($query) => $query->withCount('comments'));
Method Chaining
Chain multiple binding methods for complex requirements:
Route::get('/users/{user}/posts/{post}', fn (User $user, Post $post) => [
'user' => $user,
'post' => $post,
])
->bindWith('user', ['profile'])
->bindWithCount('user', ['posts'])
->bindQuery('post', fn ($query, User $user) => $query
->where('user_id', $user->id)
->with('tags'));
Nested Resource Scoping
Scope child resources to their parent models:
Route::get('/teams/{team}/projects/{project}/tasks/{task}',
fn (Team $team, Project $project, Task $task) => $task
)
->bindQuery('project', fn ($query, Team $team) => $query
->where('team_id', $team->id))
->bindQuery('task', fn ($query, Team $team, Project $project) => $query
->where('project_id', $project->id));
Configuration
Publish the configuration file:
php artisan vendor:publish --tag=query-binding-config
// config/query-binding.php
return [
'global_middleware' => true,
];
Middleware
The package registers a query-bindings middleware alias. Use it if you disable global middleware:
Route::middleware('query-bindings')->group(function () {
Route::get('/users/{user}', fn (User $user) => $user)
->bindSelect('user', ['id', 'name']);
});
How It Works
- Route macros register query callbacks in a singleton registry
- When routes are resolved, the registered callback is retrieved
- The model class is determined via reflection on the controller signature
- A fresh query builder is created and the callback is applied
- The model is resolved using the customized query
- The resolved model replaces the route parameter value
Standard Laravel binding handles parameters without registered callbacks.
Requirements
- PHP 8.2+
- Laravel 11.x or 12.x
Testing
composer test
Run with coverage:
composer test:coverage
Static analysis:
composer analyse
Code formatting:
composer format
Changelog
Please see CHANGELOG for recent changes.
Contributing
Contributions are welcome! Please see CONTRIBUTING for details.
Security
If you discover a security vulnerability, please contact us
Credits
License
MIT License. See LICENSE for details.