Search by

prolaxu / laravel-intentql

prolaxu

IntentQL turns natural-language prompts into safe, validated Laravel database queries. The language model plans; it never writes SQL.

Package info

github.com/prolaxu/laravel-intentql

pkg:composer/prolaxu/laravel-intentql

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-09-09 18:53 UTC

This package is auto-updated.

Last update: 2026-09-09 18:58:19 UTC


README

Ask your Laravel database a question in plain English. Get a safe, validated query back.

Latest Version License

$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

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/HAVING expressions 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_QUERY rather than a wrong answer.
  • JSON columns can only be tested for null. Path queries are driver-specific and out of scope for now.
  • MorphTo relations 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 makes whereHas relation 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 typed AiResponse, 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 and root rather than being handled as a second shape.
  • Value coercion is its own stage. "5000" for a numeric column and "Completed" for an enum storing completed are 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 resolve EntityInspector from the container.

License

MIT. See LICENSE.