A powerful and elegant Laravel package that brings GraphQL-like flexibility to your REST APIs. Built on top of Spatie Laravel Query Builder, it provides a fluent API for managing complex queries with ease.
- âś… Sorting - Sort results by multiple fields with ascending/descending order
- âś… Default Sorting - Define default sorting behavior
- âś… Sparse Fieldsets - Request only the fields you need
- âś… Virtual Fields - Support for computed/accessor fields that aren't database columns
- âś… Filters - Support for exact, partial, scope, callback, operator, exclusion, and custom filters
- âś… Includes (Relationships) - Load relationships with count, exists, and custom includes
- âś… Pagination - Built-in pagination support
- âś… Config Classes - Organize query configurations in dedicated classes
- âś… Fluent API - Beautiful and intuitive API with method chaining
- âś… HMVC Support - Full compatibility with rawnoq/laravel-hmvc package
- âś… Artisan Command - Generate QueryAPI config classes easily
- âś… Helper Methods - Built-in helpers for checking requested fields and includes in Resources
- âś… Security - Whitelist-based access control for fields, filters, and relationships
- PHP >= 8.2
- Laravel >= 12.0
- Spatie Laravel Query Builder >= 6.0
Install the package via Composer:
composer require rawnoq/laravel-query-apiThe package will automatically register itself via Laravel's package discovery.
The easiest way to get started is to generate a QueryAPI config class using the Artisan command:
# Generate a QueryAPI config for a model
php artisan make:query-api UserQueryAPI --model=User
# Or let it guess the model from the class name
php artisan make:query-api UserQueryAPI
# For HMVC modules (requires rawnoq/laravel-hmvc)
php artisan make:query-api SettingQueryAPI --model=Setting --module=SettingsThis will create a file at app/QueryAPI/UserQueryAPI.php (or modules/{Module}/App/QueryAPI/ for HMVC) with all the basic methods stubbed out.
use App\Models\User;
// Simple query
$users = query_api(User::class)->get();
// With sorting and pagination
$users = query_api(User::class)
->sort(['name', 'created_at'])
->paginate(20);Create a config class to organize your query settings:
namespace App\QueryAPI;
use Rawnoq\QueryAPI\QueryAPIConfig;
class UserQueryAPI extends QueryAPIConfig
{
public static function fields(): array
{
return ['id', 'name', 'email', 'created_at'];
}
public static function sorts(): array
{
return ['id', 'name', 'created_at'];
}
public static function defaultSort(): string
{
return '-created_at'; // Descending by created_at
}
public static function filters(): array
{
return [
self::filter()->exact('id'),
self::filter()->partial('name'),
self::filter()->partial('email'),
];
}
public static function includes(): array
{
return [
self::include()->relationship('posts'),
self::include()->count('postsCount', 'posts'),
];
}
}Use the config class in your controller:
use App\QueryAPI\UserQueryAPI;
// Direct static call
$users = UserQueryAPI::get();
$users = UserQueryAPI::paginate(20);
// Flexible pagination methods
$users = UserQueryAPI::getOrPaginate(); // Default: get all, use ?paginate=1 for pagination
$users = UserQueryAPI::paginateOrGet(); // Default: paginate, use ?get=1 for all
// With custom query
$users = UserQueryAPI::for(User::where('is_active', true))->get();GET /api/users?sort=name # Ascending
GET /api/users?sort=-created_at # Descending
GET /api/users?sort=name,-created_at # MultipleGET /api/users?fields[users]=id,name,email
GET /api/users?fields[users]=id,name&fields[posts]=id,titlePartial Filter (LIKE):
GET /api/users?filter[name]=johnExact Filter:
GET /api/users?filter[id]=1
GET /api/users?filter[status]=activeOperator Filters:
GET /api/users?filter[age]=>25 # Greater than
GET /api/users?filter[price]=<100 # Less than
GET /api/users?filter[score]=>50 # Dynamic operatorScope Filters:
GET /api/users?filter[active]=1Exclusion Filters:
GET /api/users?filter[exclude_status]=deleted # WHERE status != 'deleted'
GET /api/users?filter[exclude_id]=1,2,3 # WHERE id NOT IN (1,2,3)GET /api/users?include=posts # Single relationship
GET /api/users?include=posts,roles # Multiple relationships
GET /api/users?include=posts.comments # Nested relationships
GET /api/users?include=postsCount # Relationship count
GET /api/users?include=postsExists # Relationship existsGET /api/users?page=2&per_page=15Flexible Pagination Methods:
# getOrPaginate() - Default: get all, use ?paginate=1 for pagination
GET /api/users # Returns all users (Collection)
GET /api/users?paginate=1 # Returns paginated (LengthAwarePaginator)
GET /api/users?paginate=1&per_page=50 # Returns paginated with custom per_page
# paginateOrGet() - Default: paginate, use ?get=1 for all
GET /api/users # Returns paginated (LengthAwarePaginator)
GET /api/users?get=1 # Returns all users (Collection)
GET /api/users?per_page=50 # Returns paginated with custom per_pageGET /api/users?include=posts&fields[users]=id,name,email&fields[posts]=id,title&filter[name]=john&filter[status]=active&sort=-created_at&page=1&per_page=20// Set target model or query
->for(User::class)
->for(User::where('active', true))
// Configure allowed fields
->fields(['id', 'name', 'email'])
// Configure allowed sorting
->sort(['id', 'name', 'created_at'])
->defaultSort('-created_at')
// Configure allowed filters
->filters(['name', 'email'])
// Configure allowed includes
->includes(['posts', 'roles'])
// Set config class
->config(UserQueryAPI::class)
// Execute query
->get()
->paginate(20) // or paginate() to use defaultPerPage() from config
// Flexible pagination methods
->getOrPaginate() // Default: get all, use ?paginate=1 for pagination
->paginateOrGet() // Default: paginate, use ?get=1 for allself::filter()->exact('id')
self::filter()->exact('status')self::filter()->partial('name')
self::filter()->partial('email')self::filter()->beginsWith('email')self::filter()->endsWith('domain')self::filter()->scope('active')
self::filter()->scope('published')self::filter()->callback('has_posts', function ($query) {
$query->whereHas('posts');
})use Rawnoq\QueryAPI\Enums\FilterOperator;
self::filter()->operator('age', FilterOperator::GREATER_THAN)
self::filter()->operator('price', FilterOperator::LESS_THAN)
self::filter()->operator('salary', FilterOperator::DYNAMIC) // Allows: >3000, <100, etc.self::filter()->trashed()// Exclude single value (WHERE field != value)
self::filter()->exclude('status', 'internal_status')
// Exclude multiple values (WHERE field NOT IN [...])
self::filter()->excludeIn('id', 'internal_id')
// WHERE NOT with operator
self::filter()->whereNot('status', '!=', 'internal_status')self::include()->relationship('posts')
self::include()->relationship('profile', 'userProfile') // With aliasself::include()->count('postsCount', 'posts')self::include()->exists('postsExists', 'posts')self::include()->callback('latest_post', function ($query) {
$query->latestOfMany();
})self::include()->custom('comments_sum_votes', new AggregateInclude('votes', 'sum'), 'comments')You can configure pagination defaults and limits per model:
class UserQueryAPI extends QueryAPIConfig
{
/**
* Default items per page
*/
public static function defaultPerPage(): int
{
return 15; // Default: 20
}
/**
* Maximum items per page
*/
public static function maxPerPage(): int
{
return 100; // Default: 100
}
/**
* Minimum items per page
*/
public static function minPerPage(): int
{
return 1; // Default: 1
}
}Usage:
// Uses defaultPerPage() if per_page not in request
$users = UserQueryAPI::paginate();
// Reads per_page from request, applies min/max limits
$users = UserQueryAPI::paginate(); // Request: ?per_page=50
// Flexible methods
$users = UserQueryAPI::getOrPaginate(); // ?paginate=1&per_page=50
$users = UserQueryAPI::paginateOrGet(); // ?per_page=50 or ?get=1$query = User::where('is_active', true)
->where('role', 'admin');
$users = UserQueryAPI::for($query)->get();$users = query_api(User::class)
->fields(['id', 'name', 'email'])
->filters([
filter_exact('id'),
filter_partial('name'),
])
->includes([
'posts',
'roles',
])
->sort(['name', 'created_at'])
->defaultSort('-created_at')
->paginate(20);use Rawnoq\QueryAPI\Facades\QueryAPI;
$users = QueryAPI::for(User::class)
->fields(['id', 'name'])
->sort(['name'])
->get();Virtual fields are computed fields or accessors that aren't actual database columns. They can be requested in API calls but won't cause SQL errors.
class UserQueryAPI extends QueryAPIConfig
{
public static function fields(): array
{
return ['id', 'name', 'email', 'full_name']; // full_name is virtual
}
public static function virtualFields(): array
{
return ['full_name']; // Declare as virtual
}
}use App\QueryAPI\UserQueryAPI;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->when(
UserQueryAPI::isFieldRequested('id'),
$this->id
),
'name' => $this->when(
UserQueryAPI::isFieldRequested('name'),
$this->name
),
'email' => $this->when(
UserQueryAPI::isFieldRequested('email'),
$this->email
),
'full_name' => $this->when(
UserQueryAPI::isFieldRequested('full_name'),
$this->first_name . ' ' . $this->last_name
),
'posts' => $this->when(
UserQueryAPI::isIncludeRequested('posts'),
fn () => PostResource::collection($this->posts)
),
];
}
}The package provides helper methods for checking requested fields and includes:
// Check if a field is requested
UserQueryAPI::isFieldRequested('email', $request);
// Get all requested fields
$fields = UserQueryAPI::getRequestedFields($request);
// Check if an include is requested
UserQueryAPI::isIncludeRequested('posts', $request);
// Get all requested includes
$includes = UserQueryAPI::getRequestedIncludes($request);The package includes several performance optimizations:
- Model Table Caching: Table names are cached to avoid repeated model instantiation
- Efficient Field Parsing: Optimized parsing of field formats
- Lazy Loading: Relationships are only loaded when explicitly requested
To clear the model table cache (useful for testing):
use Rawnoq\QueryAPI\QueryAPI;
QueryAPI::clearModelTableCache();The package implements a whitelist-based security model:
- Fields - Only explicitly allowed fields can be selected
- Virtual Fields - Computed fields that are validated but not queried from database
- Filters - Only explicitly allowed filters can be applied
- Includes - Only explicitly allowed relationships can be loaded
- Sorts - Only explicitly allowed fields can be sorted
Any unauthorized request will be silently ignored or throw an exception based on Spatie Query Builder configuration.
Generate a new QueryAPI configuration class:
php artisan make:query-api {name} --model={ModelName}Arguments:
name- The name of the QueryAPI config class (e.g., UserQueryAPI)
Options:
--model, -m- The model that this QueryAPI config is for--module- The module that this QueryAPI config belongs to (for HMVC structure)--force, -f- Create the class even if it already exists
Examples:
# Generate with explicit model
php artisan make:query-api UserQueryAPI --model=User
# Generate and let it auto-detect the model
php artisan make:query-api PostQueryAPI
# Generate for a specific module (HMVC)
php artisan make:query-api SettingQueryAPI --model=Setting --module=Settings
# Force overwrite existing file
php artisan make:query-api UserQueryAPI --model=User --forceYou can publish the command stub for customization:
php artisan vendor:publish --tag=query-api-stubsThis will copy the stub file to stubs/query-api.stub in your project root where you can customize it.
Handling Empty Results:
$users = UserQueryAPI::for(User::where('deleted', true))->get();
if ($users->isEmpty()) {
return response()->json(['message' => 'No users found'], 404);
}Custom Query with Filters:
$activeUsers = UserQueryAPI::for(
User::where('status', 'active')
->where('verified', true)
)->paginate(10);Multiple Field Formats:
# All these formats work:
GET /api/users?fields=id,name
GET /api/users?fields[users]=id,name
GET /api/users?fields[_]=id,nameVirtual Fields with Nested Resources:
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->when(
UserQueryAPI::isFieldRequested('id'),
$this->id
),
'full_name' => $this->when(
UserQueryAPI::isFieldRequested('full_name'),
"{$this->first_name} {$this->last_name}"
),
'posts' => $this->when(
UserQueryAPI::isIncludeRequested('posts'),
fn() => PostResource::collection($this->posts)
),
];
}
}Complex Filtering:
// In your QueryAPIConfig
public static function filters(): array
{
return [
// Multiple filters on same field
self::filter()->exact('status'),
self::filter()->partial('name'),
// Exclusion filters
self::filter()->exclude('exclude_status', 'status'),
self::filter()->excludeIn('exclude_ids', 'id'),
// Operator filters
self::filter()->operator('age', FilterOperator::GREATER_THAN),
self::filter()->operator('price', FilterOperator::DYNAMIC), // Allows >, <, >=, <=
// Callback with complex logic
self::filter()->callback('has_recent_posts', function ($query, $value) {
if ($value) {
$query->whereHas('posts', function ($q) {
$q->where('created_at', '>', now()->subDays(7));
});
}
}),
];
}Issue: "Requested field(s) are not allowed"
// Make sure the field is in your fields() method
public static function fields(): array
{
return ['id', 'name', 'email']; // Add missing field here
}Issue: Virtual field causing SQL errors
// Make sure to declare it in virtualFields()
public static function virtualFields(): array
{
return ['full_name', 'value']; // Add virtual field here
}Issue: Includes not loading
// Check if the include is allowed
public static function includes(): array
{
return [
self::include()->relationship('posts'), // Make sure it's here
];
}
// And check if it's requested in the Resource
'posts' => $this->when(
UserQueryAPI::isIncludeRequested('posts'),
fn() => $this->posts
)Issue: Model class not found
// Make sure model() method returns correct class
public static function model(): string
{
return User::class; // Use full namespace if needed: \App\Models\User::class
}Issue: Performance with large datasets
// Use pagination and limit fields
$users = UserQueryAPI::paginate(20); // Instead of get()
// Request only needed fields
GET /api/users?fields[users]=id,name&per_page=20Issue: Filter not working
// Check filter type matches your use case
// For exact match:
self::filter()->exact('status') // ?filter[status]=active
// For partial match:
self::filter()->partial('name') // ?filter[name]=john (matches "john", "johnny", etc.)
// For exclusion:
self::filter()->exclude('exclude_status', 'status') // ?filter[exclude_status]=deletedcomposer testPlease see CHANGELOG for more information on recent changes.
Contributions are welcome! Please feel free to submit a Pull Request.
If you discover any security-related issues, please email info@rawnoq.com instead of using the issue tracker.
- Rawnoq
- Spatie for the amazing Laravel Query Builder
- All contributors
The MIT License (MIT). Please see License File for more information.