aponahmed / laravel-miniql
A Laravel GraphQL-lite internal query engine — single endpoint, schema-driven, N+1-safe, mutation-ready.
Requires
- php: ^8.2
- illuminate/cache: ^10.0 || ^11.0 || ^12.0
- illuminate/console: ^10.0 || ^11.0 || ^12.0
- illuminate/database: ^10.0 || ^11.0 || ^12.0
- illuminate/http: ^10.0 || ^11.0 || ^12.0
- illuminate/routing: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
Requires (Dev)
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0
- phpunit/phpunit: ^10.0 || ^11.0 || ^12.0 || ^13.0
README
A production-grade, single-endpoint internal query engine for Laravel.
Think GraphQL — without the schema language, without the AST parser, with full Laravel idioms.
Table of Contents
- What is MiniQL?
- Installation
- Quick Start
- Query Syntax Reference
- Mutation Syntax Reference
- Schema Config Reference
- Resolvers
- Mutation Handlers
- Hooks
- Fluent PHP API
- Caching
- Security
- Introspection
- Artisan Commands
- Architecture Overview
- Upgrading / Roadmap
What is MiniQL?
MiniQL lets your frontend (or internal services) send expressive queries to a single POST endpoint instead of building dozens of REST routes.
POST /api/miniql
A request looks like this:
{
"query": {
"users": {
"where": { "active": true },
"fields": ["id", "name", "email"],
"relations": {
"posts": {
"fields": ["id", "title"],
"orderBy": { "column": "created_at", "direction": "desc" },
"limit": 5
}
},
"orderBy": { "column": "name", "direction": "asc" },
"page": 1,
"perPage": 20
}
}
}
Everything you send is validated against a whitelist schema. No raw SQL exposure. No N+1.
Installation
composer require yourvendor/miniql
Publish the config:
php artisan vendor:publish --tag=miniql-config
Quick Start
1. Register your models in config/miniql.php
'models' => [ 'users' => [ 'model' => App\Models\User::class, 'fields' => ['id', 'name', 'email', 'created_at'], 'relations' => ['posts'], 'mutations' => [ 'createUser' => App\MiniQL\Mutations\CreateUserMutation::class, 'updateUser' => App\MiniQL\Mutations\UpdateUserMutation::class, ], ], 'posts' => [ 'model' => App\Models\Post::class, 'fields' => ['id', 'title', 'body', 'user_id'], 'relations' => ['user'], 'mutations' => [ 'createPost' => App\MiniQL\Mutations\CreatePostMutation::class, ], ], ],
2. Send a query
curl -X POST http://yourapp.test/api/miniql \ -H "Content-Type: application/json" \ -d '{ "query": { "users": { "fields": ["id", "name"], "limit": 5 } } }'
Response:
{
"data": {
"users": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]
},
"errors": []
}
Query Syntax Reference
Every query node supports:
| Key | Type | Description |
|---|---|---|
fields |
string[] |
Fields to return (whitelist enforced) |
where |
object |
Simple equality OR advanced {op, value} filter |
whereIn |
object |
Column → array of values |
whereNull |
string[] |
Columns that must be NULL |
whereNotNull |
string[] |
Columns that must NOT be NULL |
search |
object |
Full-text LIKE search {term, fields[]} |
orderBy |
string|object |
String column, or {column, direction} |
limit |
int |
Max rows (hard-capped by security.max_results) |
page |
int |
Enable pagination (returns {data, meta}) |
perPage |
int |
Rows per page (default from config) |
relations |
object |
Eager-load nested relations (recursively supported) |
Advanced where example
{
"query": {
"users": {
"where": {
"age": { "op": ">=", "value": 18 },
"status": "active"
},
"whereIn": { "role": ["admin", "editor"] },
"whereNotNull": ["email_verified_at"],
"search": { "term": "alice", "fields": ["name", "email"] }
}
}
}
Pagination example
{
"query": {
"posts": {
"fields": ["id", "title", "created_at"],
"orderBy": { "column": "created_at", "direction": "desc" },
"page": 2,
"perPage": 10
}
}
}
Response shape with pagination:
{
"data": {
"posts": {
"data": [ ... ],
"meta": {
"current_page": 2,
"per_page": 10,
"total": 87,
"last_page": 9,
"from": 11,
"to": 20
}
}
},
"errors": []
}
Multi-type query (batch)
{
"query": {
"users": { "fields": ["id", "name"], "limit": 3 },
"posts": { "fields": ["id", "title"], "limit": 3 }
}
}
Both are resolved in a single request. No N+1. Each type runs its own eager-load strategy.
Mutation Syntax Reference
{
"mutation": {
"createUser": {
"data": {
"name": "Apon",
"email": "apon@example.com",
"password": "secret123"
}
}
}
}
- All mutations run inside a database transaction. If any mutation fails, all are rolled back.
- Multiple mutations can be sent in one request.
{
"mutation": {
"createUser": { "data": { "name": "Bob", "email": "bob@x.com", "password": "pass1234" } },
"createPost": { "data": { "title": "Hello World", "user_id": 1 } }
}
}
Query + Mutation in one request
{
"mutation": {
"updateUser": { "id": 3, "data": { "name": "Updated" } }
},
"query": {
"users": { "where": { "id": 3 }, "fields": ["id", "name"] }
}
}
Mutations run first, then queries — so the query returns the updated state.
Schema Config Reference
Full annotated config (published to config/miniql.php):
'models' => [ 'users' => [ // ✅ Required: Eloquent model class 'model' => App\Models\User::class, // ✅ Required: whitelisted selectable fields 'fields' => ['id', 'name', 'email', 'created_at'], // Whitelisted eager-loadable relation names 'relations' => ['posts', 'profile'], // Custom resolver (overrides default query builder) 'resolver' => App\MiniQL\Resolvers\UserResolver::class, // Mutation handlers, keyed by mutation name 'mutations' => [ 'createUser' => App\MiniQL\Mutations\CreateUserMutation::class, 'updateUser' => App\MiniQL\Mutations\UpdateUserMutation::class, 'deleteUser' => App\MiniQL\Mutations\DeleteUserMutation::class, ], // Before/after hooks for query and mutation lifecycle 'hooks' => [ 'before_query' => App\MiniQL\Hooks\UserBeforeQueryHook::class, 'after_query' => App\MiniQL\Hooks\UserAfterQueryHook::class, 'before_mutation' => null, 'after_mutation' => null, ], // Global Eloquent scopes applied to every query on this type // e.g. 'active' calls ->active() on the query builder 'scopes' => ['active'], // Free-form metadata (documentation, versioning, etc.) 'meta' => ['description' => 'Registered users'], ], ],
Resolvers
A Resolver replaces the default query builder for a specific type. Use it for:
- Multi-tenant scoping
- Auth-based filtering
- Complex joins that can't be expressed in
where
Generate a resolver
php artisan miniql:make-resolver UserResolver
Implement it
// app/MiniQL/Resolvers/UserResolver.php namespace App\MiniQL\Resolvers; use MiniQL\Resolvers\BaseResolver; use Illuminate\Database\Eloquent\Builder; class UserResolver extends BaseResolver { protected function model(): string { return \App\Models\User::class; } public function query(array $node): Builder { $q = parent::query($node); // applies basic where filters // Scope to current tenant $q->where('tenant_id', auth()->user()->tenant_id); return $q; } }
Register it
// config/miniql.php 'users' => [ 'resolver' => App\MiniQL\Resolvers\UserResolver::class, ... ],
Mutation Handlers
Generate a mutation
php artisan miniql:make-mutation CreateUser
Implement it
// app/MiniQL/Mutations/CreateUserMutation.php namespace App\MiniQL\Mutations; use MiniQL\Mutations\BaseMutation; use App\Models\User; use Illuminate\Support\Facades\Hash; class CreateUserMutation extends BaseMutation { protected function rules(): array { return [ 'data.name' => 'required|string|max:255', 'data.email' => 'required|email|unique:users,email', 'data.password' => 'required|string|min:8', ]; } public function handle(array $node): mixed { $data = $this->validate($node); // throws ValidationException on failure return User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), ]); } }
$this->validate($node) runs Laravel's Validator against rules(). On failure it throws a ValidationException that is caught by the controller and returned in the errors key.
Hooks
Hooks let you tap into the query/mutation lifecycle without modifying the engine.
| Hook | Receives | Called |
|---|---|---|
before_query |
&$node (array) |
Before the query executes |
after_query |
&$result (mixed) |
After query result is built |
before_mutation |
&$node (array) |
Before mutation handler runs |
after_mutation |
&$result (mixed) |
After mutation handler returns |
// app/MiniQL/Hooks/AuditAfterMutationHook.php namespace App\MiniQL\Hooks; use MiniQL\Contracts\HookInterface; class AuditAfterMutationHook implements HookInterface { public function handle(mixed &$context): void { logger()->info('[Audit] Mutation completed.', ['result' => $context]); } }
Register in config:
'hooks' => [ 'after_mutation' => App\MiniQL\Hooks\AuditAfterMutationHook::class, ],
Fluent PHP API
Use MiniQL directly in PHP (controllers, jobs, services) without HTTP:
use MiniQL\Facades\MiniQL; // Simple $users = MiniQL::query('users') ->fields(['id', 'name', 'email']) ->where('active', true) ->whereIn('role', ['admin', 'editor']) ->orderBy('name', 'asc') ->limit(50) ->get(); // Paginated $result = MiniQL::query('posts') ->fields(['id', 'title', 'created_at']) ->with('user', ['fields' => ['id', 'name']]) ->orderBy('created_at', 'desc') ->paginate(15, 1) ->get(); // $result['data'], $result['meta'] // Full payload $result = MiniQL::execute([ 'query' => [ 'users' => ['fields' => ['id', 'name'], 'limit' => 5], ], 'mutation' => [ 'createUser' => ['data' => ['name' => 'Eve', 'email' => 'eve@x.com', 'password' => 'pass1234']], ], ]);
Caching
Enable Redis-backed query caching in .env:
MINIQL_CACHE=true
MINIQL_CACHE_TTL=120
MINIQL_CACHE_STORE=redis
Cache keys are derived from type + md5(node) — identical queries return cached results instantly.
Cache is automatically invalidated per-type when a mutation runs (if cache.auto_invalidate = true).
Security
All security options live in config/miniql.php under security:
| Option | Default | Description |
|---|---|---|
max_depth |
5 |
Max relation nesting depth |
max_query_nodes |
10 |
Max top-level query types per request |
max_results |
1000 |
Hard cap on rows returned |
require_auth |
false |
Set to true to require authentication globally |
rate_limit |
60 |
Requests per minute per IP (0 = disabled) |
Field & relation whitelisting is always enforced — there is no way to query a field not listed in config('miniql.models.*.fields').
Protect the endpoint by adding auth:sanctum (or any Laravel middleware) to the route middleware:
// config/miniql.php 'route' => [ 'middleware' => ['api', 'auth:sanctum'], ],
Introspection
GET /api/miniql/schema
Returns the full registered schema as JSON (useful for frontend tooling):
{
"schema": {
"users": {
"fields": ["id", "name", "email", "created_at"],
"relations": ["posts"],
"mutations": ["createUser", "updateUser", "deleteUser"],
"meta": {}
}
},
"version": "1.0"
}
Disable in production:
MINIQL_INTROSPECTION=false
Or protect with middleware:
'introspection' => [ 'enabled' => true, 'middleware' => ['auth:sanctum'], ],
Artisan Commands
| Command | Description |
|---|---|
miniql:make-resolver Name |
Scaffold a resolver class |
miniql:make-mutation Name |
Scaffold a mutation handler class |
miniql:schema-dump |
Pretty-print the registered schema |
miniql:schema-dump --json |
Output schema as raw JSON |
miniql:schema-validate |
Verify all model/resolver/mutation classes exist on disk |
Architecture Overview
POST /api/miniql
│
▼
MiniQLController
│
├─ SchemaValidator ← whitelist check (fields, relations, depth)
│
├─ QueryEngine
│ ├─ ResolverInterface (custom) OR default Eloquent builder
│ ├─ Eager loading ← N+1 prevention via ->with()
│ ├─ QueryCache ← Redis-backed result cache
│ └─ Hooks ← before/after lifecycle
│
└─ MutationEngine
├─ DB::transaction ← atomic multi-mutation
├─ BaseMutation ← Laravel validation built-in
└─ Hooks ← before/after lifecycle
Roadmap
Possible future upgrades:
- Query complexity scoring (prevent expensive queries by weight)
- String DSL parser — accept
users { id name posts { title } }syntax - WebSocket subscriptions — real-time updates on mutations
- Persisted queries — hash-indexed pre-registered queries
- Automatic SQL optimization — detect and merge redundant joins
- OpenAPI export — generate an OpenAPI 3 spec from the schema config
License
MIT © Muhiminul Haque