An AI agent can reason, converse, and explain. It becomes genuinely useful when it can query your application's own data.
That does not mean giving a model unrestricted SQL access. A safer design exposes carefully shaped tools. The model chooses a tool, sends structured arguments, and Laravel decides exactly what reaches the database.
This tutorial builds that pattern with the Laravel AI SDK. It follows the production lessons from Laravel's database tools engineering post, including the support agent built for Nova and Spark.
Start with the Laravel AI SDK
Install the SDK and publish its configuration:
composer require laravel/ai
php artisan vendor:publish \
--provider="Laravel\Ai\AiServiceProvider"
php artisan migrate
Create an agent and a first database tool:
php artisan make:agent DataAssistant
php artisan make:tool OrdersByStatus
The SDK uses three methods for local tools:
-
description()tells the agent when to use the tool. -
schema()defines its structured inputs. -
handle()executes the application code.
This gives Laravel a clear boundary between language understanding and data access.
Individual Eloquent tools: one tool, one query

Individual Eloquent tools are the best starting point. Each class handles one known query. The scope stays narrow, the output stays predictable, and the tool is easy to test.
The most important detail is the constructor. Pass the authenticated user's ID from application code. Never accept it from the prompt.
<?php
namespace App\Ai\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class OrdersByStatus implements Tool
{
public function __construct(
protected int $userId,
) {}
public function description(): Stringable|string
{
return 'Find the current user’s recent orders filtered by order status.';
}
public function schema(JsonSchema $schema): array
{
return [
'status' => $schema->string()
->enum(['pending', 'paid', 'shipped', 'cancelled'])
->required(),
];
}
public function handle(Request $request): Stringable|string
{
$status = $request->string('status')->toString();
return Order::query()
->where('user_id', $this->userId)
->where('status', $status)
->select(['id', 'status', 'total', 'created_at'])
->latest()
->limit(10)
->get()
->toJson();
}
}
The user ID cannot be changed by prompt injection. select() limits the columns returned to the model. limit() keeps the result small.
Register the tool on an agent:
<?php
namespace App\Ai\Agents;
use App\Ai\Tools\OrdersByStatus;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
class DataAssistant implements Agent, HasTools
{
use Promptable;
public function __construct(
protected int $userId,
) {}
public function instructions(): string
{
return <<<'INSTRUCTIONS'
Answer questions about the user's account data.
Use tools when database information is required.
Never invent records or claim that a query returned data when it did not.
INSTRUCTIONS;
}
public function tools(): iterable
{
return [
new OrdersByStatus($this->userId),
];
}
}
Call the agent from application code:
use App\Ai\Agents\DataAssistant;
$response = (new DataAssistant($request->user()->id))
->prompt('Show me my paid orders from this month.');
return (string) $response;
This pattern works well for invoices, subscriptions, support tickets, and other known questions.
When individual tools stop scaling
A focused tool is a strong unit of design. However, every tool description and schema is sent to the model.
At around a dozen tools, two problems become visible:
- Tool definitions consume a large part of the context window.
- The model has more opportunities to choose the wrong tool.
You also start writing a new PHP class for every variation of a valid question. That is the point to consider one structured query-builder tool.
A safe query-builder tool

The query-builder approach exposes one tool. Its description() acts as a schema map for the agent. Its schema() defines tables and structured filters. Its handle() validates every identifier before calling DB::table().
The model never creates raw SQL.
php artisan make:tool ReadOnlyDatabaseQuery
Here is a complete example for three user-scoped tables:
<?php
namespace App\Ai\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class ReadOnlyDatabaseQuery implements Tool
{
private const MAX_OUTPUT_LENGTH = 12000;
private const COLUMNS = [
'subscriptions' => [
'id', 'status', 'plan', 'renews_at', 'created_at',
],
'invoices' => [
'id', 'status', 'amount', 'currency', 'issued_at',
],
'tickets' => [
'id', 'status', 'subject', 'created_at', 'resolved_at',
],
];
private const OPERATORS = [
'=', '!=', '>', '>=', '<', '<=', 'like', 'in',
];
public function __construct(
protected int $userId,
) {}
public function description(): Stringable|string
{
return <<<'DESCRIPTION'
Query the user's read-only account data. Available tables:
subscriptions(id, status, plan, renews_at, created_at),
invoices(id, status, amount, currency, issued_at),
tickets(id, status, subject, created_at, resolved_at).
Every query is automatically scoped to the authenticated user.
Use filters for precise lookups. Results are limited and redacted.
DESCRIPTION;
}
public function schema(JsonSchema $schema): array
{
return [
'table' => $schema->string()
->enum(array_keys(self::COLUMNS))
->required(),
'columns' => $schema->array()
->items($schema->string())
->required(),
'filters' => $schema->array()
->items($schema->object(fn ($filter) => [
'column' => $filter->string()->required(),
'operator' => $filter->string()
->enum(self::OPERATORS)
->required(),
'value' => $filter->anyOf([
$filter->string(),
$filter->integer(),
$filter->boolean(),
$filter->array(),
])->required(),
])),
];
}
public function handle(Request $request): Stringable|string
{
$table = $this->validateTable(
$request->string('table')->toString()
);
$columns = $this->validateColumns(
$table,
$request->array('columns')
);
$query = DB::connection('readonly')
->table($table)
->select($columns)
->where('user_id', $this->userId);
foreach ($this->validateFilters($table, $request->array('filters')) as $filter) {
if ($filter['operator'] === 'in') {
$query->whereIn(
$filter['column'],
$filter['value']
);
continue;
}
$query->where(
$filter['column'],
$filter['operator'],
$filter['value']
);
}
$rows = $query
->latest('created_at')
->limit(50)
->get()
->all();
return $this->formatOutput(
$this->redact($rows)
);
}
private function validateTable(string $table): string
{
if (! array_key_exists($table, self::COLUMNS)) {
throw new InvalidArgumentException('Table is not allowed.');
}
return $table;
}
private function validateColumns(string $table, array $columns): array
{
$allowed = self::COLUMNS[$table];
$columns = $columns ?: $allowed;
if (array_diff($columns, $allowed)) {
throw new InvalidArgumentException('Column is not allowed.');
}
return array_values(array_unique($columns));
}
private function validateFilters(string $table, array $filters): array
{
$allowedColumns = self::COLUMNS[$table];
return array_map(function (array $filter) use ($allowedColumns) {
$column = $filter['column'] ?? null;
$operator = strtolower($filter['operator'] ?? '');
if (! in_array($column, $allowedColumns, true)) {
throw new InvalidArgumentException('Filter column is not allowed.');
}
if (! in_array($operator, self::OPERATORS, true)) {
throw new InvalidArgumentException('Filter operator is not allowed.');
}
if ($operator === 'in' && ! is_array($filter['value'] ?? null)) {
throw new InvalidArgumentException('The in operator requires an array.');
}
return [
'column' => $column,
'operator' => $operator,
'value' => $filter['value'] ?? null,
];
}, $filters);
}
private function redact(array $rows): array
{
return array_map(function ($row) {
$data = (array) $row;
foreach ($data as $column => $value) {
if (preg_match('/(_token|_secret|_password|_key)$/i', $column)) {
$data[$column] = '[REDACTED]';
}
}
return $data;
}, $rows);
}
private function formatOutput(array $rows): string
{
$encoded = json_encode($rows, JSON_THROW_ON_ERROR);
while (strlen($encoded) > self::MAX_OUTPUT_LENGTH && count($rows) > 1) {
array_pop($rows);
$encoded = json_encode($rows, JSON_THROW_ON_ERROR);
}
if (strlen($encoded) <= self::MAX_OUTPUT_LENGTH) {
return $encoded;
}
return substr($encoded, 0, self::MAX_OUTPUT_LENGTH - 24)
. '...[output truncated]';
}
}
Values are bound by PDO through the query builder. Column names and operators are different. PDO cannot bind them, so both must be validated against explicit allowlists.
The query builder also physically cannot produce DELETE or DROP. Safety comes from the API's structure, not from parsing raw SQL strings with keyword blocklists.
Register this tool instead of the individual tool when needed:
public function tools(): iterable
{
return [
new \App\Ai\Tools\ReadOnlyDatabaseQuery($this->userId),
];
}
The Laravel AI SDK does not ship a magic built-in text-to-SQL parser. The production-safe approach is structured “text to query” behavior. The agent selects arguments, while your API controls the query that those arguments can express.
Add a read-only database connection
The application should use a dedicated database user with SELECT-only grants on the exposed tables.
Add credentials to .env:
DB_READONLY_HOST=127.0.0.1
DB_READONLY_PORT=5432
DB_READONLY_DATABASE=app
DB_READONLY_USERNAME=app_readonly
DB_READONLY_PASSWORD=secret
Add a connection in config/database.php:
'connections' => [
'readonly' => [
'driver' => 'pgsql',
'host' => env('DB_READONLY_HOST'),
'port' => env('DB_READONLY_PORT', 5432),
'database' => env('DB_READONLY_DATABASE'),
'username' => env('DB_READONLY_USERNAME'),
'password' => env('DB_READONLY_PASSWORD'),
'charset' => 'utf8',
'prefix' => '',
'search_path' => 'public',
'sslmode' => 'prefer',
],
],
Also listen for Laravel AI SDK events such as ToolInvoked and ToolFailed. Log the tool name, table, requested columns, and filter shape. Avoid logging sensitive values.
Finally, test adversarial prompts. Try to access another user's records, select a blocked column, use an unsupported operator, or query a table outside the enum. These should fail before reaching the database.
Use SimilaritySearch for fuzzy questions

Structured filters cannot answer questions such as:
“How do I request a refund?”
That is a semantic search problem. The SDK includes the SimilaritySearch tool for this use case.
First, add a vector column. This example uses PostgreSQL with pgvector:
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::ensureVectorExtensionExists();
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('title');
$table->text('content');
$table->vector('embedding', dimensions: 1536)->index();
$table->timestamps();
});
Cast the embedding on the model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\AsVector;
use Illuminate\Database\Eloquent\Model;
class Document extends Model
{
protected function casts(): array
{
return [
'embedding' => AsVector::class,
];
}
}
Register the tool with a tenant or user scope:
use App\Models\Document;
use Laravel\Ai\Tools\SimilaritySearch;
public function tools(): iterable
{
return [
SimilaritySearch::usingModel(
model: Document::class,
column: 'embedding',
minSimilarity: 0.7,
limit: 10,
query: fn ($query) => $query->where(
'user_id',
$this->userId
),
),
];
}
Laravel generates or accepts embeddings and uses vector similarity to find relevant records. Keep the threshold and result limit explicit. Semantic relevance does not replace authorization.
Which approach should you choose?
| Approach | Best for | Setup effort | Context window cost | Security model | Joins/aggregates |
|---|---|---|---|---|---|
| Individual Eloquent tools | Known, predictable queries | One class per query | Grows with tool count | Constructor-scoped ID and fixed query | Full Eloquent power |
SimilaritySearch |
Fuzzy or semantic questions | Vector column and one tool | One compact tool | Model or closure scope | Not intended for relational reports |
| Query-builder tool | Unpredictable structured questions | One tool plus allowlists | One compact tool | Allowlists, binding, redaction, read-only connection | Best for flat queries; complex joins and aggregates need dedicated tools |
Start with individual Eloquent tools. Add SimilaritySearch when users ask meaning-based questions. Move to the query-builder approach when you keep writing new database tools every week.
These patterns also work beyond a chat interface. You can expose the agent through an authenticated controller, return streamed responses, or expose approved tool operations as JSON endpoints. If your team wants to build rest api with php, Laravel can provide the routing, authentication, validation, authorization, and response layer around the same tool classes.
That is the useful boundary: natural language on one side, explicit Laravel code on the other. With Laravel as your php web framework and the AI SDK among your modern php developer tools, your application can answer questions about its data without handing control of the database to the model.