Just Symfony, Redis and MySQL.

I kept seeing AI agent projects built in Python with LangGraph. Every tutorial, every article, every repo. All Python. I have been working with PHP for years. So I wanted to know one thing. Can we do the same in PHP?

I tried it. It works. This is what I learned.

What I built

A small NL2SQL agent. You ask a question in normal English. It writes the SQL, runs it on MySQL, and gives you the answer back in English.

But it is not just a prompt that writes SQL. It behaves like an agent:

  • It first decides whether the question can be answered at all.
  • If the question is unclear, it stops and asks you for more detail.
  • If the data does not exist in the database, it refuses instead of guessing.
  • When it writes a wrong query, it reads the MySQL error and fixes the query itself.
Repo is here if you want to look at the code:
https://github.com/premsgdev/agentic-nl2sql-with-php

First, what is LangGraph actually doing?

When I started I thought LangGraph is something complicated. It is not.

If you remove the branding, it is three things:

  1. A state object. One bag of data that moves through the whole run.
  2. Nodes and edges. A node is a function. It takes the state, changes something, and returns it. An edge decides which node runs next.
  3. A checkpoint. After each node, the state is saved somewhere so the run can stop and continue later.

This might be an oversimplification but at its core, the mental model is a stateful graph: shared state moves through nodes, edges determine what runs next, and check-pointing allows the execution to be persisted and resumed.

The problem

In the traditional PHP-FPM model, the application state is not tied to a long-lived application process. Each HTTP request is handled independently, so anything held only in request memory cannot be relied upon for the next request.

So when my agent asks a clarification question, the process ends there. The user thinks for some time and replies. That reply comes to a completely new PHP process. It has no idea what happened before.

I use FrankenPHP, so I thought worker mode will solve this. It does not. Worker mode only keeps the app warm. There are many workers, they restart after some number of requests, and if you run two containers it breaks anyway. You cannot keep a conversation in memory.

So the only way is to save the state properly and load it back using an id.

At first I felt this is a PHP limitation. Later I understood it is actually better. A Python agent also loses in-memory state when its process or pod disappears unless that state has been persisted externally. But in Python you can ignore this for a long time. In PHP you cannot ignore it even on day one.

The graph engine

This is the main part. It is around 50 lines.

final class Graph
{
public const END = '__end__';

private array $nodes = [];
private array $routes = [];

public function __construct(private readonly int $maxSteps = 15) {}

public function node(string $name, NodeInterface $node): self
{
$this->nodes[$name] = $node;
return $this;
}

public function edge(string $from, string $to): self
{
$this->routes[$from] = static fn (State $state): string => $to;
return $this;
}

public function conditionalEdge(string $from, callable $router): self
{
$this->routes[$from] = $router;
return $this;
}

public function run(State $state, string $entry): State
{
$current = $state->resumeAt ?? $entry;
$state->resumeAt = null;

while (self::END !== $current) {
if (++$state->steps > $this->maxSteps) {
throw new StepLimitException($state->steps, $state->trace);
}

$state->trace[] = $current;
$state = ($this->nodes[$current])($state);
$current = ($this->routes[$current])($state);
}

return $state;
}
}

A node is any class with this one method:

interface NodeInterface
{
public function __invoke(State $state): State;
}

Two small things in that loop that are important.

The step limit. Without it, one wrong condition gives you an infinite loop. And with LLM calls inside the nodes, that means burning your API quota while you wait. Four lines prevent it.

Loops are allowed. A node can go back to a previous node. That is what makes it a graph and not a pipeline. My normal clarification run looks like this:

classify → clarify → classify → generate

It came back to classify with the updated question and took the other path this time.

How the agent stops and continues

When a node wants to ask the user something, it throws an exception:

final class Interrupt extends \RuntimeException
{
public function __construct(
public readonly string $question,
public readonly string $resumeAt,
) {
parent::__construct($question);
}
}

I used an exception because it comes out of the while loop automatically. The node only says “stop here”. The caller decides what to do. The graph engine itself needs no extra code for this.

The runner catches it, saves the state, and returns the question:

private function execute(State $state): RunResult
{
try {
$state = $this->graph->run($state, 'classify');
} catch (Interrupt $interrupt) {
$state->resumeAt = $interrupt->resumeAt;
$this->checkpointer->save($state);

return RunResult::paused($state, $interrupt->question);
}

$this->checkpointer->save($state);

return RunResult::completed($state);
}

Next time, run() starts from resumeAt instead of the first node.

One mistake I made here. First I set resumeAt to clarify. So when the user replied, it asked the same question again. It has to be classify, because the question has changed now and it needs to be checked again.

Saving the state

I store it in Redis as JSON. Not PHP serialize().

public function save(State $state): void
{
$this->redis->setex(
$this->prefix . $state->threadId,
$this->ttl,
json_encode($state->toArray(), JSON_THROW_ON_ERROR),
);
}

Tool calling

Symfony has symfony/ai now. You mark a normal PHP class with an attribute and the model can call it.

#[AsTool('run_sql', 'Executes a read-only SELECT query and returns rows as JSON.')]
final readonly class RunSql
{
public function __construct(private \PDO $pdo) {}

public function __invoke(string $sql): string
{
$sql = trim($sql, " \t\n\r;");

if (!preg_match('/^(SELECT|WITH)\b/i', $sql)) {
return 'ERROR: only SELECT statements are allowed.';
}

if (!preg_match('/\bLIMIT\s+\d+/i', $sql)) {
$sql .= ' LIMIT 50';
}

try {
$rows = $this->pdo->query($sql)->fetchAll(\PDO::FETCH_ASSOC);
} catch (\PDOException $e) {
return 'ERROR: ' . $e->getMessage();
}

return json_encode(['row_count' => count($rows), 'rows' => $rows]);
}
}

The important line is the catch block. I return the error as a string. I do not throw it.

That string goes back to the model. So when it writes SELECT SUM(revenue), MySQL says "Unknown column 'revenue'", the model reads it and writes total_amount instead. It fixes its own query. If I had thrown the exception, the whole run would stop and I would get a stack trace instead of an answer.

About security

I know this part is not optional, for now what I did is created read-only user to query the data, also implemented regex checking to make sure the model generated only select statements and to block any insert or delete operations. Regex checking is only a first guardrail, not a SQL security boundary. The database user is read-only, and the application should additionally validate the parsed SQL/AST, restrict accessible tables and operations, enforce query timeouts and resource limits, and ideally execute against a read replica or isolated analytics database.

Working

$ bin/console app:ask "How much did we sell last year?"

I need one more detail
----------------------
Specify the order statuses (e.g., PAID, CONFIRMED, etc.) to include
in the sales total

// bin/console app:ask "your answer" --thread=40d87234bfc76bb2

$ bin/console app:ask "DELIVERED" --thread=40d87234bfc76bb2

Answer
------
We sold 1,132,046.00 in total for orders marked DELIVERED during last year.

Look at what happened there, It did not guess. My orders table has statuses like CANCELLED and RETURNED. So the total changes depending on which ones you count. There is no correct answer without asking. Then the PHP process ended. I ran the second command separately. A different process continued the same conversation and gave the answer. Two processes. One conversation.

About the AI tools I used

I used Qwen Coder, Trae AI and opencode, these were cheaper and worked fine for what I needed.

What I did not build

I think this part is important, so I will say it clearly.

No RAG for the schema. My database has four tables. The whole schema fits inside the prompt easily. RAG over schema is useful when you have a few hundred tables. Below that it only makes things worse by hiding a table the query needed.

Redis is not fully safe. When memory is full, Redis can remove a key. Then a paused conversation is gone. For production the correct way is MySQL as the main store and Redis as a cache. I kept it behind an interface so that change is small.

No evaluation setup. I test with three questions manually. A real system needs a set of question and SQL pairs and a pass percentage in CI, because when you change the prompt or the model the accuracy drops silently.

What I actually learned

The model call is maybe 5 percent of the code. And AI tools wrote a good part of the rest. But the decisions were mine, and that is where the time went.

Everything else was normal engineering. Where does the state go. What happens when the output is broken. How much is this query allowed to touch. How do I stop an infinite loop.

That is the part that makes a demo into something usable. PHP just forced me to think about it on day one instead of finding out later in production.

The thing that took me a full evening

My classifier asks the model to reply in JSON. Something like this:

{"label": "ambiguous", "reason": "...", "missing": "..."}

It kept failing. My parsing was using this regex:

preg_match('/\{.*\}/s', $raw, $matches);

Then I printed the raw response and understood the problem. New reasoning models write their thinking first, inside a <think> block. And inside that thinking, the model writes draft JSON two or three times before the final answer.

So \{.*\} was matching from the very first { to the very last }. It picked up several lines of normal text in between. Not valid JSON.

The fix:

// Reasoning models write a <think> block first. Remove it.
if (false !== ($end = strripos($text, '</think>'))) {
$text = substr($text, $end + 8);
}
// Then take the last plain JSON object.
if (preg_match_all('/\{[^{}]*\}/s', $text, $matches)) {
$text = end($matches[0]);
}

[^{}]* cannot go across other braces, so it will not pick up the text in between.

If you use GPT-OSS, Qwen or DeepSeek models, you will hit this. I did not see it mentioned anywhere.

Never trust the model output directly

Even after parsing, I check the label against a fixed list:

private const LABELS = ['answerable', 'ambiguous', 'out_of_scope'];
$label = strtolower(trim((string) ($data['label'] ?? '')));
if (!in_array($label, self::LABELS, true)) {
return [
'label' => 'ambiguous',
'reason' => 'Unknown label',
'missing' => 'Could you rephrase your question?',
];
}

And see which side it falls back to. If something goes wrong, it falls back to ambiguous, not to answerable. So a question that was never properly classified can never reach the SQL step.

This is the general rule I followed everywhere: the model suggests, PHP decides.

Stack

Symfony 8.1, PHP 8.4, symfony/ai, Groq, MySQL, Redis.

Code: https://github.com/premsgdev/agentic-nl2sql-with-php