ghostcompiler/laravel-querybuilder
API-ready Eloquent query builder for Laravel with strict allow-lists for filters, sorts, includes, and sparse fields. Supports nested relation filters/sorting, custom filters, tenant scoping, safe public query interfaces, and pagination helpers for clean API responses.
Installation:
composer require ghostcompiler/laravel-querybuilder
Publish the config (optional):
php artisan vendor:publish --provider="GhostCompiler\QueryBuilder\QueryBuilderServiceProvider"
Define a Schema:
Create a schema class for your model (e.g., UserSchema):
use GhostCompiler\QueryBuilder\Contracts\Schema;
class UserSchema implements Schema
{
public function getFilters(): array
{
return [
'name' => ['operator' => 'like'],
'email' => ['operator' => 'like'],
'active' => ['operator' => 'eq'],
];
}
public function getSorts(): array
{
return ['name', 'created_at'];
}
public function getIncludes(): array
{
return ['posts', 'roles'];
}
public function getFields(): array
{
return ['id', 'name', 'email', 'active'];
}
}
First Use Case: Apply the trait to your model:
use GhostCompiler\QueryBuilder\Eloquent\QueryBuilder;
class User extends Model
{
use QueryBuilder;
protected static function getSchema(): string
{
return UserSchema::class;
}
}
Now, your API endpoint can handle requests like:
/users?name=John&sort=-created_at&fields=id,name&include=posts
Schema-Driven API Endpoints:
public function index(Request $request)
{
$query = User::query()->applyQuery($request);
return $query->paginate($request->input('per_page', 15));
}
applyQuery() processes all allowed filters, sorts, includes, and fields from the request.Nested Relation Filtering: Define nested filters in your schema:
public function getFilters(): array
{
return [
'posts.title' => ['operator' => 'like'],
'posts.published' => ['operator' => 'eq'],
];
}
Usage:
/users?posts.title=Laravel
Strict Mode:
Enable strict mode in config/query-builder.php to reject unknown parameters:
'strict' => env('QUERY_BUILDER_STRICT', true),
Or per-query:
User::query()->applyQuery($request, strict: true);
Custom Filters: Register custom filters in your schema:
public function getCustomFilters(): array
{
return [
'role' => [RoleFilter::class],
];
}
Implement the filter:
class RoleFilter implements CustomFilter
{
public function apply(Builder $query, $value)
{
return $query->whereHas('roles', fn($q) => $q->where('name', $value));
}
}
Pagination Helpers: Use built-in pagination logic:
$query->paginate($request->input('per_page', 15));
Or disable pagination:
$query->disablePagination();
Sparse Fieldsets:
Limit returned fields via fields parameter:
/users?fields=id,name,email
Relation Includes: Include related models:
/users?include=posts,roles
Or with nested includes:
/users?include=posts.author
Middleware for Tenant Awareness: Use middleware to scope queries by tenant:
public function handle(Request $request, Closure $next)
{
$request->merge(['tenant_id' => auth()->tenant()->id]);
return $next($request);
}
Then define tenant-aware filters in your schema.
Policy-Aware Includes: Restrict includes based on user permissions:
public function getIncludes(): array
{
return [
'posts' => ['policy' => CanViewPosts::class],
'roles' => ['policy' => CanViewRoles::class],
];
}
Dynamic Schemas: Use closures for dynamic schemas:
protected static function getSchema(): string|Closure
{
return fn () => new DynamicUserSchema(request()->user());
}
JSON:API Compliance: Map query parameters to JSON:API conventions:
?filter[name][like]=John&sort=-createdAt&fields=id,name&include=posts
Use the jsonapi config option to enable this.
Caching Queries: Cache compiled query logic:
$query = User::query()->applyQuery($request)->remember();
Testing: Mock schemas and requests:
$request = new Request(['name' => 'John', 'sort' => '-created_at']);
$query = User::query()->applyQuery($request);
$this->assertDatabaseCount('users', 1);
Strict Mode Overhead:
strict: true rejects unknown parameters before validation, which can lead to unexpected 400 responses. Test thoroughly.strict: false during development and enable it in production.Nested Relation Performance:
posts.author.profile) can cause N+1 queries. Use with() or loadMissing() manually if needed.loadMissing to your schema:
public function getIncludes(): array
{
return [
'posts' => ['loadMissing' => true],
];
}
Custom Filter Edge Cases:
null or empty strings).whereRaw with bindings).public function apply(Builder $query, $value)
{
if (!is_string($value)) {
throw new \InvalidArgumentException('Value must be a string.');
}
return $query->where(...);
}
Schema Caching:
php artisan config:clear
php artisan query-builder:clear-schema-cache if provided.Field Masking Conflicts:
password) are masked by default. If you explicitly include them in getFields(), they will still be masked unless configured otherwise.getFields() or override masking:
public function getFields(): array
{
return ['id', 'name', 'email'];
}
public function getMaskedFields(): array
{
return ['password', 'api_token'];
}
Pagination Parameter Conflicts:
page parameter is used for pagination, but some libraries (e.g., Laravel Scout) also use it. Rename it in your schema:
public function getPaginationParameter(): string
{
return 'p';
}
Relation Include Validation:
getIncludes(), but nested includes (e.g., posts.author) must be explicitly defined in the schema.public function getIncludes(): array
{
return ['posts.*']; // Allows posts.author, posts.comments, etc.
}
Query Builder Chaining:
applyQuery() multiple times on the same query. It re-applies all filters, sorts, etc.// Bad
User::query()->applyQuery($request)->applyQuery($request);
// Good
$query = User::query()->applyQuery($request);
return $query->where('active', true)->get();
Log Queries:
Enable query logging in config/query-builder.php:
'debug' => env('QUERY_BUILDER_DEBUG', false),
Or log manually:
\DB::enableQueryLog();
User::query()->applyQuery($request)->get();
\Log::info(\DB::getQueryLog());
Validate Schema:
Use the validateSchema method to check your schema definition:
User::validateSchema();
This throws exceptions for invalid configurations.
**Inspect Applied Query
How can I help you explore Laravel packages today?