Adding an Intelligence Layer to Your Laravel App
Most Laravel apps already hold the answer to almost any question a user could ask. “How much did I spend on groceries last month?” is a SELECT away. The problem is that the user does not write SQL, and you do not want to build a bespoke report screen for every question someone might think of.
An intelligence layer closes that gap: the user types a question in plain English, and the app answers it from their own data. This post walks through an architecture for doing that safely in Laravel, using the setup behind Trakli, a personal finance app, as the concrete example. Trakli uses SmartQL as the engine that turns questions into validated SQL.
The shape of the problem
A naive version of this feature is one controller that takes a question, asks an LLM for SQL, runs it, and returns rows. It demos well and breaks in production for three reasons:
- It is slow. An LLM round-trip plus a query can take several seconds. Doing that inside a web request ties up a PHP worker and risks timeouts.
- It is unsafe. Whatever SQL the model produces runs against your database. Without enforcement, a generated query can read tables and rows the user should never see.
- It is dumb about your domain. A raw schema does not tell the model that
type = 'expense'means an outgoing transaction, or that “revenue” is a sum of one specific column. It guesses, and guesses wrong.
The architecture below addresses each one. None of the pieces are exotic; they are the same patterns you already use for any slow, sensitive, domain-specific job.
SmartQL as a sidecar service
SmartQL is a Python tool, so Trakli runs it as its own container alongside the Laravel app and talks to it over HTTP. In docker-compose.yml it is one service:
smartql:
image: ghcr.io/smartql/smartql:latest
environment:
SMARTQL_CONFIG: /app/smartql.yml
DB_HOST: mysql
DB_DATABASE: ${DB_DATABASE}
LLM_PROVIDER: ${LLM_PROVIDER:-gemini}
LLM_API_KEY: ${LLM_API_KEY}
volumes:
- ./smartql.yml:/app/smartql.yml:ro
command: ["smartql", "serve", "-c", "/app/smartql.yml", "--host", "0.0.0.0", "--port", "8000"]
The Laravel side is a thin client. A small service class wraps the HTTP call and, crucially, attaches who is asking:
class AiService
{
public function ask(string $question, int $userId, ?string $language = null): array
{
$response = Http::timeout(60)->post("{$this->baseUrl}/ask", [
'question' => $question,
'execute' => true,
'context' => [
'user_id' => $userId,
'language' => $language ?? app()->getLocale(),
],
]);
return $response->successful()
? ['success' => true, 'data' => $response->json()]
: ['success' => false, 'error' => __('Failed to process your question.')];
}
}
Keeping SmartQL as a separate service, rather than shelling out to Python from PHP, means it scales, restarts, and deploys independently, and the only contract between the two is a JSON request.
Move the slow work off the request
A question can take several seconds to answer, so Trakli never does it inside the HTTP request. The user’s message is saved, an assistant message is created in a “processing” state, and the actual work happens in a queued job:
class ProcessChatMessageJob implements ShouldQueue
{
public int $timeout = 120;
public int $tries = 2;
public function handle(AiService $aiService, AiRouter $router): void
{
// ... load the user's question ...
$response = $aiService->ask(
question: $userMessage->content,
userId: (int) $userMessage->user_id,
language: $userMessage->language,
);
// ... store the answer on the assistant message ...
}
}
The frontend shows the message as pending and updates when the job finishes. This is the standard Laravel queue pattern, and it is exactly right here: the web request returns instantly, retries are free ($tries = 2), and a slow LLM never blocks a worker.
Route the question before you answer it
Not every message needs the database. “What can you help me with?” is a conversation, not a query. Running it through a text-to-SQL engine wastes a call and produces nonsense.
Trakli puts a small router in front. It classifies the incoming message: questions about the user’s data go to SmartQL, everything else is answered as plain conversation by a lighter LLM call.
$route = $router->classify($userMessage->content);
if ($route === AiRouter::ROUTE_GENERAL) {
$this->answerGeneral($router, $userMessage->content);
return;
}
// data question: hand off to SmartQL
There is a second benefit. When a data query comes back empty, that is often a sign the question was really a general one in disguise. Trakli treats an empty result as a soft failure and falls back to a conversational answer, so the user gets a helpful reply instead of “0 rows”.
Teach the model your domain with a semantic layer
This is the piece that separates a toy from something usable. SmartQL reads a YAML semantic layer that describes what the tables and columns actually mean, so the model stops guessing. Trakli’s looks like this:
semantic_layer:
entities:
transactions:
table: transactions
description: "Financial transactions representing income or expenses"
aliases: [spending, expenses, income, payments]
columns:
amount: { type: decimal, description: "Transaction amount" }
type: { type: string, description: "income or expense" }
business_rules:
- name: expense
definition: "type = 'expense'"
- name: this_month
definition: "MONTH(datetime) = MONTH(CURRENT_DATE()) AND YEAR(datetime) = YEAR(CURRENT_DATE())"
Now “how much did I spend this month?” has a clear path: spending is an alias for transactions, “spend” maps to the expense rule, “this month” maps to a defined date filter. Define your business vocabulary once, and every question inherits it. A few worked examples in the same config sharpen the model further.
Make per-user isolation the database’s job, not the model’s
Trakli is multi-user. Every user must see only their own transactions, wallets, and categories. The dangerous way to do this is to hope the model adds WHERE user_id = ... to every query. Hope is not a security control.
Instead, SmartQL enforces it. Trakli marks the user-owned tables in its security config, and SmartQL guarantees that any query touching them is constrained to the current user, the user_id that the Laravel app put in the request context. A query that omits the filter is not run unfiltered; it is rejected, or the filter is injected before execution. The model’s output is treated as untrusted, and tenancy is enforced on the parsed query rather than requested in a prompt.
security:
mode: read_only
max_rows: 100
allowed_tables: [transactions, wallets, categories, parties]
required_filters:
transactions: { column: user_id }
wallets: { column: user_id }
Because the scoping key comes from the authenticated session and not from the user’s text, there is no phrasing of a question that returns someone else’s data. Admins and service roles that genuinely need system-wide queries can be granted an explicit bypass, but the default is locked down. (The reasoning behind validating generated SQL on its parse tree, rather than trusting the prompt, is its own post.)
The whole loop
Putting it together, one question flows like this:
- User sends a message; Laravel stores it and dispatches
ProcessChatMessageJob. - The router decides: conversation or data question.
- For a data question, the app calls the SmartQL service with the question and the user’s id.
- SmartQL consults the semantic layer, generates SQL, scopes it to the user, validates it, runs it read-only, and returns rows plus a written answer.
- The job saves the answer; the frontend updates.
Every slow step is queued, every generated query is validated and scoped, and the model is given enough domain context to be accurate. That is an intelligence layer you can actually put in front of users.
You can run the same engine against PostgreSQL, MySQL, SQLite, or SQL Server:
pip install smartql
SmartQL is open source under the MIT license. The docs cover the semantic layer, the security model, and running it as a service.