pxl/laravel-query-binding

Declarative route model binding with full query builder control for Laravel

Maintainers

Package info

github.com/pxl-no/laravel-query-binding

pkg:composer/pxl/laravel-query-binding

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v1.0.0 2026-02-07 20:22 UTC

This package is auto-updated.

Last update: 2026-04-07 21:41:31 UTC


README

Declarative route model binding with full query builder control.

Latest Version on Packagist Total Downloads Tests PHPStan

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

  1. Route macros register query callbacks in a singleton registry
  2. When routes are resolved, the registered callback is retrieved
  3. The model class is determined via reflection on the controller signature
  4. A fresh query builder is created and the callback is applied
  5. The model is resolved using the customized query
  6. 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.