prolaxu / laravel-intentql
IntentQL turns natural-language prompts into safe, validated Laravel database queries. The language model plans; it never writes SQL.
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- nesbot/carbon: ^2.72|^3.0
Requires (Dev)
- laravel/pint: ^1.15
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^2.34|^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Ask your Laravel database a question in plain English. Get a safe, validated query back.
$result = IntentQL::for(Order::class) ->allowFields(['id', 'agent_id', 'status', 'total', 'created_at']) ->prompt('Show completed orders this month grouped by agent with revenue') ->get();
The language model never writes SQL. It only fills in a constrained JSON query plan, which is parsed into a typed AST, validated field by field against the real schema, authorized by a policy, and only then compiled with Laravel's query builder.
Table of contents
- The problem this solves
- Architecture
- Installation
- Configuration
- Gemini setup
- Your first query
- Schema inspection
- Allowed and denied fields
- Relations
- Aggregates and HAVING
- Nested filters
- Date filters
- Explain and debug mode
- The security model
- Custom AI providers
- Custom query policy
- Custom compiler
- Events
- Schema cache
- Testing
- Limitations
The problem this solves
The obvious way to build "ask your database a question" is:
prompt → LLM → SQL → database
This is unsafe, and no amount of prompt engineering fixes it. The model can hallucinate tables, read columns you never meant to expose, emit a query that scans a billion rows, or be talked into something else entirely by text a user typed. You cannot review SQL you did not write, and you cannot bind values you did not choose.
This package inverts the relationship. The model is a planner, not a generator:
prompt → LLM → constrained JSON plan → validation → AST → Laravel query builder → database
Everything after the planner is deterministic, inspectable and testable. The model returns field names and operators drawn from a schema you handed it; if it returns anything else, the query is rejected before a connection is touched.
Architecture
Natural-language prompt
↓
Model + schema inspection Schema\ModelInspector, DatabaseSchemaInspector, RelationInspector
↓
Normalized entity metadata Metadata\EntityMetadata (cached)
↓
Constraint narrowing Validation\QueryConstraints — allow/deny fields and relations
↓
Prompt builder Prompt\QueryPromptBuilder + PromptSchemaSerializer
↓
Language model AI\Contracts\LanguageModel (Gemini, OpenAI, Ollama, yours)
↓
Structured JSON query definition
↓
Structural parsing Query\QueryDefinitionFactory — shape and identifier gate
↓
Value normalization Validation\QueryNormalizer — coercion and limit clamping
↓
Schema validation Validation\QueryValidator — fields, operators, types, limits
↓
Authorization Validation\Contracts\QueryPolicy
↓
Query AST Query\QueryDefinition (immutable IR)
↓
Compiler Compiler\EloquentQueryCompiler
↓
Laravel query builder → database Runtime\QueryExecutor
Each arrow is a class boundary with its own tests. The four boundaries designed to be
replaced are LanguageModel, SchemaInspector, QueryCompiler and QueryPolicy.
Installation
composer require prolaxu/laravel-intentql
The service provider and IntentQL facade are registered through package discovery.
php artisan vendor:publish --tag=intentql-config
Requires PHP 8.2+ and Laravel 11 or 12.
Configuration
config/intentql.php:
return [ 'provider' => env('INTENTQL_PROVIDER', 'gemini'), 'providers' => [ 'gemini' => [ 'driver' => 'gemini', 'api_key' => env('GEMINI_API_KEY'), 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'), 'base_url' => env('GEMINI_BASE_URL', 'https://generativelanguage.googleapis.com'), ], 'openai' => [...], 'ollama' => [...], ], 'limits' => [ 'max_results' => 1000, 'default_results' => 100, 'max_select' => 50, 'max_filters' => 25, 'max_group_by' => 10, 'max_aggregates' => 10, 'max_having' => 10, 'max_sort' => 5, 'max_relation_filters' => 5, 'max_relation_depth' => 2, 'max_filter_depth' => 5, 'max_in_values' => 100, 'max_value_length' => 500, ], 'security' => [ 'denied_fields' => ['password', 'remember_token', 'api_token', ...], 'exclude_hidden' => true, ], 'relations' => ['auto_discover' => true], 'dates' => [ 'timezone' => env('INTENTQL_TIMEZONE'), 'week_starts_at' => 'monday', ], 'cache' => [ 'enabled' => true, 'store' => null, 'ttl' => 3600, 'prefix' => 'intentql_schema:', ], ];
Structural limits (filters, depth, aggregates) reject a plan that exceeds them.
max_results clamps instead, so a planner asking for 10,000 rows quietly gets 1,000.
Gemini setup
INTENTQL_PROVIDER=gemini GEMINI_API_KEY=your-key GEMINI_MODEL=gemini-2.5-flash
The provider calls generateContent directly through Laravel's HTTP client — no SDK.
It requests application/json at temperature 0, retries on transient failures, and
turns transport problems into typed exceptions: AiProviderException for HTTP 429,
API errors and unreachable endpoints; InvalidAiResponseException for a blocked,
empty or unparsable completion. The API key is redacted from every message.
Asking without naming a data source
When the caller has a single text box and no idea which table the question is about,
across() routes the prompt first, then plans against whatever it picked:
$result = IntentQL::across([Order::class, Invoice::class, Ticket::class, Deal::class]) ->ask('Top 10 customers by revenue this year') ->get(); $result->model(); // App\Models\Invoice — the source that answered
Routing is a separate, deliberately cheap call: the planner sees a catalogue of names, labels, descriptions and a field sample, and returns one name. A name that is not in the catalogue is rejected, so routing cannot widen access. With one candidate the call is skipped entirely.
Give a candidate its own restrictions, and they apply once it is chosen:
use Prolaxu\IntentQL\Runtime\EntityCandidate; IntentQL::across([ new EntityCandidate( model: Order::class, name: 'orders', label: 'Sales orders', description: 'Customer orders: status, totals, agent, dates.', constraints: (new QueryConstraints)->denyingFields(['internal_cost']), ), new EntityCandidate(model: Ticket::class, name: 'tickets'), ])->ask('Revenue by agent this month')->get();
If nothing fits, the router returns NO_MATCHING_ENTITY and you get an
UnsupportedQueryException rather than an answer from the wrong table.
Tune the catalogue under catalog in the config: include_fields and
max_fields_per_entity trade routing accuracy against prompt size.
Your first query
use Prolaxu\IntentQL\Facades\IntentQL; $result = IntentQL::for(Order::class) ->ask('Show completed orders above 5000 this month grouped by agent') ->get(); $result->data(); // Collection of rows $result->definition(); // the validated QueryDefinition $result->sql(); // the compiled SQL $result->bindings(); // the bound values
ask() and prompt() are the same method; pick whichever reads better. Nothing runs
until you call get(), compile() or explain().
get() returns hydrated Eloquent models for a plain query, and plain row objects for a
grouped or aggregated one — a GROUP BY result is not a model.
$query = IntentQL::for(Order::class) ->prompt('Top 10 customers by revenue this year') ->compile(); $query->builder(); // Illuminate\Database\Eloquent\Builder — chain onto it freely $query->toSql(); $query->bindings();
Schema inspection
The package inspects both halves of a model and merges them:
| Source | What it contributes |
|---|---|
| Database | columns, storage types, nullability, primary key, indexes, foreign keys, enum members |
| Model | casts, $fillable/$guarded, $hidden, table, connection, soft deletes, relations |
A cast wins over the storage type, so a tinyint cast to boolean is filtered as a
boolean, and a decimal cast column is filtered as a number. Absent a cast, MySQL's
tinyint(1) is read as a boolean, since that is how BOOL is stored.
A column cast to a PHP backed enum contributes its members too. The planner is shown the case names, and the matching stored value is bound:
protected $casts = ['status' => OrderStatus::class]; // enum OrderStatus: int { case COMPLETED = 12; }
"show completed orders" → status = ? with binding 12
Without this the planner sees a bare enum with no members and correctly refuses to
guess. Database-level enum(...) columns work the same way, with label and stored value
being identical.
A table that reports no columns raises EntityNotInspectableException rather than
producing an empty schema — usually a sign the model points at a database that is not
the active connection.
$metadata = IntentQL::inspect(Order::class); $metadata->table; // 'orders' $metadata->primaryKey; // 'id' $metadata->field('total')->logicalType; // FieldType::NUMBER $metadata->field('total')->cast; // 'decimal:2' $metadata->relationNames(); // ['customer', 'agent', 'items']
Storage types are normalized to logical types the planner can reason about:
enum FieldType: string { case STRING; case INTEGER; case NUMBER; case BOOLEAN; case DATE; case DATETIME; case JSON; case UUID; case ENUM; }
Allowed and denied fields
By default a query may touch any column that is neither in the model's $hidden array
nor in security.denied_fields. Narrow it per query:
IntentQL::for(Order::class) ->allowFields(['status', 'total', 'created_at', 'agent_id']) ->denyFields(['internal_cost', 'deleted_at']) ->prompt('Revenue by agent this month') ->get();
Restrictions apply twice: the narrowed schema is what the planner sees, and the validator re-checks the returned plan against the same list. A column you deny is invisible upstream and rejected downstream.
When the planner selects no columns at all, the compiler selects the visible columns
explicitly rather than emitting select *, so a hidden column cannot leak through the
default projection.
Relations
Discovery is conservative: only public, parameterless methods that declare a supported Eloquent relation return type are considered, and no other model method is ever invoked.
public function customer(): BelongsTo // discovered public function explode() // never called — no Relation return type
Supported: belongsTo, hasOne, hasMany, belongsToMany, hasOneThrough,
hasManyThrough, morphOne, morphMany. MorphTo is excluded — its target table is
not knowable in advance.
Restrict traversal per query, by exact path:
IntentQL::for(Order::class) ->allowRelations(['customer', 'agent', 'items.product']) ->ask('Show completed orders where customer country is Nepal') ->get();
Or declare it on the model, which disables discovery for that model entirely:
public function intentqlRelations(): array { return ['customer', 'agent', 'items.product']; }
Set relations.auto_discover to false to make relations opt-in globally.
Relation filters compile to whereHas / whereDoesntHave, never to a join the planner
controls. Counts work too:
// "Show customers with more than 5 orders" ['relation' => 'orders', 'count_operator' => '>', 'count' => 5]
Depth is capped by limits.max_relation_depth (default 2).
Aggregates and HAVING
'aggregates' => [ ['function' => 'count', 'field' => 'id', 'as' => 'total_orders'], ['function' => 'sum', 'field' => 'total', 'as' => 'revenue'], ], 'having' => [['alias' => 'total_orders', 'operator' => '>', 'value' => 5]],
Supported functions are count, sum, avg, min, max, each checked against the
field's logical type — SUM(name) is rejected. Aliases must be plain identifiers, must
be unique, and may not collide with a real column.
HAVING is fully implemented, not stubbed. A having condition may only reference an
alias the same query declares, and the compiler expands it back into the aggregate
expression rather than referencing the alias, since alias references in HAVING are a
MySQL extension rather than portable SQL.
An aggregated query may only select columns it also groups by; anything else is rejected, so the result is valid under strict SQL modes.
Nested filters
Filters are a tree, not a flat list:
{
"boolean": "and",
"conditions": [
{ "field": "status", "operator": "=", "value": "completed" },
{ "boolean": "or", "conditions": [
{ "field": "total", "operator": ">", "value": 10000 },
{ "field": "priority", "operator": "=", "value": true }
]}
]
}
compiles to
where "orders"."status" = ? and ("orders"."total" > ? or "orders"."priority" = ?)
A bare array of conditions is accepted as an implicit and group. Depth and total
condition count are both capped.
Operators are a closed set, mapped to the field types they may be used with:
| Type | Operators |
|---|---|
| string, uuid, enum | = != contains starts_with ends_with in not_in is_null is_not_null |
| integer, number | = != > >= < <= between in not_in is_null is_not_null |
| boolean | = != is_null is_not_null |
| date, datetime | = != > >= < <= between is_null is_not_null + relative dates |
| json | is_null is_not_null |
contains(total) fails validation. So does this_month(status).
contains, starts_with and ends_with compile to LIKE ... ESCAPE '!' with !, %
and _ escaped in the value, so a user searching for 100% gets a literal match on
every supported driver.
Date filters
today, yesterday, this_week, last_week, this_month, last_month, this_year,
last_year.
The planner is told never to compute a date itself — it emits the operator, and the compiler resolves it through an injectable clock:
$query->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()]);
Configure the timezone and the first day of the week under dates. For deterministic
tests, bind a frozen clock:
use Prolaxu\IntentQL\Support\{Clock, FrozenClock}; app()->instance(Clock::class, new FrozenClock('2026-03-15 13:45:00'));
Explain and debug mode
$explanation = IntentQL::for(Order::class) ->prompt('Top 10 agents by completed revenue this month') ->explain(); [ 'query' => QueryDefinition, // Arrayable + JsonSerializable 'sql' => 'select "orders"."agent_id", SUM("orders"."total") as "revenue" ...', 'bindings' => ['completed', '2026-03-01 00:00:00', '2026-03-31 23:59:59'], ]
explain() plans, validates and compiles, but never executes. $compiled->toRawSql()
gives the statement with bindings inlined, for logs.
The security model
The planner never touches a connection. It receives a JSON description of the columns you allowed and returns JSON. Everything else is the package's own code.
What is enforced, and where:
| Check | Stage |
|---|---|
Field names match ^[A-Za-z_][A-Za-z0-9_]{0,63}$ |
parsing |
| Operators and aggregate functions resolve to enum cases | parsing |
| Filter values contain no nested structures or objects | parsing |
| Structural nesting depth is bounded | parsing |
| Every field exists in the narrowed metadata | validation |
| Operator is legal for the field's logical type | validation |
| Value type matches the column | validation |
| Enum values are members of the column's enum | validation |
| Aggregate is legal for the field's type | validation |
| Aliases are unique and do not shadow columns | validation |
| Sort targets a grouped column or a declared alias | validation |
| Relation paths are allowed and within the depth budget | validation |
| Filter count, group-by count, aggregate count, sort count | validation |
Row limit clamped to max_results |
normalization |
| Referenced fields and relations re-checked against constraints | policy |
| Identifiers re-checked immediately before quoting | compilation |
What never happens:
- SQL returned by a model is never executed — the model cannot return SQL at all.
- No user value is ever interpolated; every value is a binding.
- No raw select, order or group expression is accepted from the planner. The only raw
fragments in the output are aggregate and
LIKE/HAVINGexpressions assembled by the compiler from an enum, a grammar-quoted identifier and a?placeholder. - No joins or table names come from the planner. The target model is fixed by
IntentQL::for(), and a plan naming a different entity is rejected. - Hidden and denied columns are absent from the prompt and rejected in validation.
- Compilation goes through Eloquent, so global scopes and soft deletes still apply.
Prompt injection is contained rather than trusted: the user's text is labelled as data in the message, the system instruction tells the planner to ignore instructions inside it, and — more importantly — even a fully compromised planner can only return field names and operators that survive validation.
IntentQL::for(Order::class)->ask('Show users; DROP TABLE users;')->get(); // The text stays a prompt. Nothing derived from it becomes SQL.
Custom AI providers
interface LanguageModel { public function generate(QueryPrompt $prompt): AiResponse; public function name(): string; }
Three ship in the box: gemini, openai (any chat-completions endpoint, including a
self-hosted gateway) and ollama (local models, format: json).
Register your own in a service provider:
IntentQL::extend('claude', fn (array $config, Container $app) => new ClaudeProvider( $app->make(HttpFactory::class), $config['api_key'], ));
IntentQL::for(Order::class)->provider('claude')->prompt(...)->get();
Swapping the provider changes nothing else — the engine downstream is provider-agnostic.
A planner can decline. Returning
{ "error": { "code": "UNSUPPORTED_QUERY", "message": "The requested field 'profit_margin' is not available." } }
raises UnsupportedQueryException carrying the typed AiError.
Custom query policy
Schema validation answers "is this query well-formed against the schema?". The policy answers "is this caller allowed to run it?".
use Prolaxu\IntentQL\Validation\Contracts\QueryPolicy; final class TenantQueryPolicy implements QueryPolicy { public function authorize(QueryContext $context, QueryDefinition $query): void { if (! $context->user?->can('run-reports')) { throw UnauthorizedQueryException::make('reporting is not enabled for this user.'); } } }
$this->app->bind(QueryPolicy::class, TenantQueryPolicy::class);
QueryContext carries the model, the narrowed metadata, the constraints, the original
prompt and the authenticated user. Pass the user with ->actingAs($user).
For row-level scoping, apply a closure to the compiled builder after validation:
IntentQL::for(Order::class) ->prompt('Revenue this month') ->scope(fn (Builder $query) => $query->where('tenant_id', auth()->user()->tenant_id)) ->get();
Custom compiler
interface QueryCompiler { public function compile(QueryDefinition $definition, EntityMetadata $entity): CompiledQuery; }
$this->app->bind(QueryCompiler::class, MyCompiler::class);
The compiler only ever receives definitions that already passed validation and policy.
Events
Prolaxu\IntentQL\Events\QueryPlanned // context, definition, AiResponse (with token usage) Prolaxu\IntentQL\Events\QueryExecuted // context, compiled query, row count, duration in ms
Useful for logging prompts, tracking token spend and spotting slow reports.
Schema cache
Introspection does not run on every prompt. Metadata is cached as plain arrays under a versioned key, so a package upgrade can never rehydrate a stale object graph.
IntentQL::clearSchemaCache(Order::class); IntentQL::clearSchemaCache();
Clear it after a migration — a deployment hook is the natural place.
Testing
Use the bundled fake planner rather than faking HTTP:
use Prolaxu\IntentQL\Facades\IntentQL; $planner = IntentQL::fake([[ 'entity' => 'orders', 'select' => ['agent_id'], 'group_by' => ['agent_id'], 'aggregates' => [['function' => 'sum', 'field' => 'total', 'as' => 'revenue']], ]]); $result = IntentQL::for(Order::class)->ask('Revenue by agent')->get(); expect($result->count())->toBe(2); expect($planner->lastPrompt()->schema['fields'])->not->toContain('internal_cost');
Queue several payloads to script a sequence, or push a Throwable to simulate a
provider failure. For provider-level tests, Http::fake() works normally.
The package's own suite covers metadata normalization, type detection, operator/type compatibility, nested filter groups, aggregate validation, unknown fields and relations, denied fields, result limits, invalid sort fields, aggregate aliases, malformed JSON, HTTP failures, timeouts and 429s, relative dates, compiler output and bindings, injection-shaped values, SQL-shaped prompts, malicious planner output, relation depth and schema caching.
composer test
composer lint
Limitations
Known and deliberate:
- One root entity per query.
IntentQL::for()fixes the table. Cross-entity questions are expressed through relation filters, not arbitrary joins. - No window functions, CTEs, unions or subquery projections. A request needing them
should come back as
UNSUPPORTED_QUERYrather than a wrong answer. - JSON columns can only be tested for null. Path queries are driver-specific and out of scope for now.
MorphTorelations are not traversable, by design.- Relation aggregates are existence counts, via
whereHas. Selecting a related aggregate as a column (withCount) is not modelled yet. - Only date parts of relative operators are resolved. "Last 7 days" is not a built-in
operator; a planner should express it as a
between. - Schema inspection uses Laravel's introspection API, so it follows whatever that
supports per driver. Enum member detection works where the driver reports an
enum(...)column type (MySQL/MariaDB); elsewhere those columns are plain strings. - The planner still costs a round trip. Cache results at the application layer if the same question is asked repeatedly.
Notes on the design
A few deliberate departures from the obvious shape:
- Compilation targets the Eloquent builder, not
DB::table. That is what makeswhereHasrelation filters possible, and it means global scopes and soft deletes keep applying — a query that would have leaked soft-deleted rows does not. LanguageModel::generate()returns a typedAiResponse, not an array. Providers differ only in transport; payload interpretation is shared.- Filters are always a tree internally. A flat JSON array is normalized to an
implicit
androot rather than being handled as a second shape. - Value coercion is its own stage.
"5000"for a numeric column and"Completed"for an enum storingcompletedare fixed before validation, so a well-meaning planner is not punished for formatting. - Inspection is dependency-injected, not static. Use
IntentQL::inspect(Model::class)or resolveEntityInspectorfrom the container.
License
MIT. See LICENSE.