Symfony AI in Practice: Building a Writing Assistant for Dyslexic Adults
Since the kickoff of the Symfony AI initiative in July 2025, Symfony has shipped an official AI component: Symfony AI, with a Platform abstraction, an Agent layer, and a vector Store. I have already tested the Vercel AI SDK and the OpenAI Agents SDK but being a Symfony enthusiast, I wanted to know what building an AI product feels like with this component.
I like to test this kind of tool on real cases, so I needed a project. I chose to build a writing assistant for dyslexic people, for reasons that are close to my heart. This article rebuilds the app step by step, adding one Symfony AI feature at a time, with the actual code.
A Writing Assistant for Dyslexic People as a Test Project
The app targets adults with dyslexia who write real texts: emails, letters, book chapters. They paste their text and the app finds the mistakes, explains the rule behind each one, reads explanations out loud, and answers follow-up questions through a chat coach.

The stack is Symfony 8 with PHP 8.4, running on FrankenPHP via the symfony-docker setup. There is no database: state lives in the session.
One Composer Package to Get Started
The whole AI setup took me a few minutes. First, I clone symfony-docker and start the containers:
git clone https://github.com/dunglas/symfony-docker.git writing-assistantcd writing-assistantdocker compose build --no-cachedocker compose up --waitThen I install the bundle and the OpenAI bridge:
docker compose exec php composer require symfony/ai-bundle symfony/ai-open-ai-platformThe ai.yaml File Is the Core Configuration
The config/packages/ai.yaml file gathers everything about the configuration of the agents in one place, which I find quite convenient (the AI bundle documentation lists every available key). It holds the provider and its API key, an agent and its prompt:
ai: platform: openai: api_key: '%env(OPENAI_API_KEY)%' agent: review: platform: 'ai.platform.openai' model: 'gpt-4o' prompt: text: | You proofread real texts (emails, letters, book chapters) written by dyslexic adults.
Your task: [...]The bundle turns each entry into an injectable service: ai.platform.openai, the low-level client, and ai.agent.review, an AgentInterface bound to gpt-4o.
Calling the Agent Takes One Injection and One call()
The core feature is the review: the app sends the user’s text to the model and gets back a list of mistakes. Since the agent is an injectable service, I can use it like any Symfony service:
<?php
namespace App\Ai;
use Symfony\AI\Agent\AgentInterface;use Symfony\AI\Platform\Message\Message;use Symfony\AI\Platform\Message\MessageBag;use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ReviewService{ public function __construct( #[Autowire(service: 'ai.agent.review')] private readonly AgentInterface $agent, ) { }
public function review(string $text): string { $messages = new MessageBag(Message::ofUser($text));
return (string) $this->agent->call($messages)->getContent(); }}Calling the agent takes a service injection and one call(). The message bag only carries the user text, since the prompt is already attached.
This version returns prose: the model answers in free text. That works, but in my experience it is more convenient to work with a deterministic response.
A Review Service Built on Structured Output
Instead of asking for JSON in the prompt and parsing the response by hand, Symfony AI offers a structured output feature: it accepts a PHP class as response_format.
The feature needs a DTO class. Here is the Mistake class, which describes one error. The bundle turns each property’s docblock into the description field of the JSON schema, and the model reads these descriptions when filling each field, so they work as one-line instructions, one per field:
<?php
namespace App\Dto;
final class Mistake{ /** The faulty passage, copied exactly as the user wrote it */ public string $excerpt;
/** The corrected passage */ public string $correction;
/** One type among: orthographe, grammaire, conjugaison, homophone, ponctuation, registre */ public string $type;
/** The rule explained simply, in short sentences */ public string $explanation;}Another DTO class holds the list of Mistakes:
<?php
namespace App\Dto;
final class Review{ /** @var Mistake[] The mistakes found in the text, in text order */ public array $mistakes = [];}Now that I have my DTOs in place, I can use the response_format feature:
<?php
namespace App\Ai;
use App\Dto\Review;use App\Dto\ReviewedText;use Symfony\AI\Agent\AgentInterface;use Symfony\AI\Platform\Message\Message;use Symfony\AI\Platform\Message\MessageBag;use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ReviewService{ public function __construct( #[Autowire(service: 'ai.agent.review')] private readonly AgentInterface $agent, ) { }
public function review(string $text): ReviewedText { $messages = new MessageBag(Message::ofUser($text));
$result = $this->agent->call($messages, [ 'response_format' => Review::class, ]);
return new ReviewedText($result->getContent(), $result->getMetadata()->get('token_usage')); }}That is the whole review pipeline. The controller calls the service and renders the DTO with Twig, like any other Symfony data:
<?php
namespace App\Controller;
use App\Ai\ReviewService;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;
class WritingController extends AbstractController{ //...
#[Route('/review', name: 'app_review', methods: ['POST'])] public function review(Request $request, ReviewService $reviewService): Response { $text = trim((string) $request->request->get('text', '')); // ... $reviewed = $reviewService->review($text);
return $this->render('writing/index.html.twig', [ 'text' => $text, 'review' => $reviewed->review, 'tokenUsage' => $reviewed->tokenUsage, ]); }}In the template, the mistakes are a plain array of Mistake objects, so a for loop renders one card per error:
{# templates/writing/_result.html.twig #}{% for mistake in review.mistakes %} <div> <p> <span>{{ mistake.type }}</span> <span> <del>{{ mistake.excerpt }}</del> → <ins>{{ mistake.correction }}</ins> </span> </p> <p>{{ mistake.explanation }}</p> </div>{% endfor %}Every result also carries a TokenUsage object in its metadata (that is the token_usage above), and the template prints it under the answer:
{# templates/writing/_result.html.twig #}{% if tokenUsage ?? null %} <p>{{ 'result.tokens'|trans({'%prompt%': tokenUsage.promptTokens, '%completion%': tokenUsage.completionTokens}) }}</p>{% endif %}Here is the result on an English letter to the tax office, packed with mistakes:

A Text-to-Speech Button in One invoke() Call
Reading is an effort for the target users, so every explanation has a “Listen” button. Text-to-speech does not need an agent: I call the platform service directly, the low-level API the agents build on (see the Platform component documentation). One invoke() call with a TTS model, and the result comes back as binary MP3. The model name is a container parameter:
parameters: app.tts_model: 'gpt-4o-mini-tts'Now I can use it in a controller, as follows:
<?php
namespace App\Controller;
use Symfony\AI\Platform\PlatformInterface;use Symfony\Component\DependencyInjection\Attribute\Autowire;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;
class AudioController extends AbstractController{ public function audio( Request $request, #[Autowire(service: 'ai.platform.openai')] PlatformInterface $platform, #[Autowire('%app.tts_model%')] string $ttsModel, ): Response { // ... $result = $platform->invoke($ttsModel, $text, [ 'voice' => 'nova', 'instructions' => 'en' === $request->getLocale() ? 'Speak in English, slowly and clearly, in a neutral tone.' : 'Parle en français, lentement et distinctement, sur un ton neutre.', ]);
return new Response($result->asBinary(), Response::HTTP_OK, [ 'Content-Type' => 'audio/mpeg', ]); }}When a user clicks the “Listen” button, they hear the explanation read out loud.
A Coach Chat With Its Own Prompt, Without a React Front End
The next layer lets users ask follow-up questions about a rule: each mistake card opens a mini conversation with a “coach”. Like the previous agent, the config lives in the ai.yaml file:
ai: agent: coach: platform: 'ai.platform.openai' model: 'gpt-4o' prompt: text: | You are a writing coach for dyslexic adults. You are asked questions about one specific mistake already explained. Rephrase the rule DIFFERENTLY: another angle, and above all CONCRETE examples CLOSE to the word in question [...]The conversation history is kept in the session. There is nothing new on the PHP side: an agent call, like the review.
To display the response, I did not write any JavaScript. A <turbo-frame> from Symfony UX Turbo does the job: the form posts to a controller, the controller calls the coach, and renders a Twig partial back into the frame.
{# templates/chat/_panel.html.twig #}<turbo-frame id="chat-{{ chatId }}"> {# history rendered server-side, form posts back into this frame #}</turbo-frame>I found that very convenient: no client state, no API layer, no loading spinners to hand-roll. The LLM call, the conversation history, and the rendering all stay on the server. The few dynamic parts (playing MP3s, popovers) are small Stimulus controllers of 30 lines each. For an AI app, where the heavy work is server-side anyway, this fits well.

Translating the App, Prompts Included
When I built the app, I had a French version in mind. I knew I could translate the UI with the symfony/translation bundle, but I wondered if I could use it for my prompts as well. To my surprise, the same mechanism covers them: the prompt key of an agent accepts a translation key instead of the raw text:
ai: agent: review: platform: 'ai.platform.openai' model: 'gpt-4o' prompt: text: 'review.system_prompt' enable_translation: true translation_domain: 'ai_prompts' coach: platform: 'ai.platform.openai' model: 'gpt-4o' prompt: text: 'coach.system_prompt' enable_translation: true translation_domain: 'ai_prompts'With enable_translation, the bundle resolves the key through the translator with the request locale. The English prompt moves from ai.yaml to a standard translation catalog, next to its French twin:
review: system_prompt: | Tu relis des textes réels (mails, courriers, chapitres de livre) écrits par des adultes dyslexiques [...]To let users pick their language, I added a FR/EN switcher in the header. It posts to a small controller that stores the choice in the session, and an event listener applies it to every request:
<?php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;use Symfony\Component\HttpKernel\Event\RequestEvent;
/** Applies the locale chosen through LocaleController (stored in session) to every request. */#[AsEventListener(priority: 20)]final class LocaleListener{ public function __invoke(RequestEvent $event): void { $request = $event->getRequest(); if ($request->hasPreviousSession() && $locale = $request->getSession()->get('_locale')) { $request->setLocale($locale); } }}That is all it takes for the prompts to follow: setLocale() sets the request locale, the translator follows the request locale, and the agent prompts go through the translator. Prompts end up managed like any other translated string: same files, same workflow, same tooling. A French user gets a French reviewer, an English user gets an English one, and the PHP services never see a prompt.
Tool Calling Stops the Coach From Improvising Rules
The first coach had one flaw: it improvised its grammar rules, and sometimes got them wrong. I fixed it with curated rules that the agent reads through a tool.
I keep the rule cards in YAML files (config/rules/fr.yaml and en.yaml): each card holds the rule in plain words, a substitution test, and examples. For lack of an existing resource, I wrote the cards myself, with Claude’s help. They are not perfect, but my goal here is to test tool calling, not to ship a grammar reference. A tool is a class with the #[AsTool] attribute (see the Agent component documentation), and the docblock parameters describe the arguments to the model:
<?php
namespace App\Ai\Tool;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsTool('lookup_rule', 'Returns the curated card of a spelling or grammar rule: the rule in plain words, a substitution test and examples. Call it before explaining a rule that has a card.')]final class LookupRule{ public function __construct( #[Autowire('%kernel.project_dir%/config/rules')] private readonly string $rulesDir, ) { }
/** * @param string $ruleId Identifier of the rule, taken from the list in the system prompt (for example "sa_vs_ca") * @param string $locale Language of the conversation: "fr" or "en" */ public function __invoke(string $ruleId, string $locale = 'fr'): string { // load config/rules/{locale}.yaml, return the card as text }}The coach agent gets the tool attached in ai.yaml:
ai: agent: coach: platform: 'ai.platform.openai' model: 'gpt-4o' prompt: text: 'coach.system_prompt' enable_translation: true translation_domain: 'ai_prompts' tools: - 'App\Ai\Tool\LookupRule'The coach service stays a single agent call. The tool-calling loop (model asks for the tool, the bundle runs it, sends the result back, gets the final answer) is handled for me.
There is one problem left: the model can only call lookup_rule with an existing ruleId, so the system prompt must list the available cards. I don’t want to copy that list by hand into ai.yaml: it would go stale every time I add a card. This part of the prompt must be built at runtime, from the rules files.
Symfony AI has a hook for this: input processors, classes that modify the messages right before they are sent to the model. Mine reads the card catalog and appends it to the configured system prompt:
<?php
namespace App\Ai;
use App\Ai\Tool\LookupRule;use Symfony\AI\Agent\Attribute\AsInputProcessor;use Symfony\AI\Agent\Input;use Symfony\AI\Agent\InputProcessorInterface;use Symfony\AI\Platform\Message\Message;use Symfony\Contracts\Translation\TranslatorInterface;
#[AsInputProcessor(agent: 'ai.agent.coach', priority: -35)]final class RuleCatalogPrompt implements InputProcessorInterface{ public function __construct( private readonly LookupRule $lookupRule, private readonly TranslatorInterface $translator, ) { } // ... public function processInput(Input $input): void { $messages = $input->getMessageBag(); $system = $messages->getSystemMessage(); // ... $hint = $this->translator->trans( 'coach.tool_hint', ['%catalog%' => $this->lookupRule->catalog($locale)], 'ai_prompts', );
$messages->removeSystemMessage(); $messages->prepend(Message::forSystem($system->getContent()."\n\n".$hint)); }}When the question matches a card, the coach cites a verified rule, otherwise it answers on its own. The Agent layer also supports memory, which I have not needed yet.
Tool calling is not the only way to feed curated content to a model. Symfony AI also ships a Store component: I could have embedded the cards in a vector store (text-embedding-3-small) and fetched the closest one for each mistake with a similarity search. I prefer the tool approach here: the model decides when a card is relevant, instead of a similarity score deciding for it.
Switching Models Without Touching PHP: Agents and a Router
I wanted to know how hard it is to switch models, in case I need a different one for another agent someday. I chose Mistral. Adding it means one composer package (symfony/ai-mistral-platform) and one config block:
ai: platform: openai: api_key: '%env(OPENAI_API_KEY)%' mistral: api_key: '%env(MISTRAL_API_KEY)%'I don’t want if statements on model names spreading through the services, so the review agent splits in two, one per provider, same prompt:
ai: agent: review_openai: platform: 'ai.platform.openai' model: 'gpt-4o' prompt: text: 'review.system_prompt' enable_translation: true translation_domain: 'ai_prompts' review_mistral: platform: 'ai.platform.mistral' model: 'mistral-large-latest' prompt: text: 'review.system_prompt' enable_translation: true translation_domain: 'ai_prompts'The coach agent splits the same way (coach_openai, coach_mistral, and the RuleCatalogPrompt processor targets both). Model names and prompts stay in configuration: no gpt-4o string anywhere in the PHP code. A small ModelRouter service maps the provider picked in the UI to the matching agent:
<?php
namespace App\Ai;
use Symfony\AI\Agent\AgentInterface;use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class ModelRouter{ public const DEFAULT_PROVIDER = 'openai';
public function __construct( #[Autowire(service: 'ai.agent.review_openai')] private readonly AgentInterface $reviewOpenai, #[Autowire(service: 'ai.agent.review_mistral')] private readonly AgentInterface $reviewMistral, ) { }
/** The value comes from the form: any unknown provider falls back to the default. */ public function review(string $provider): AgentInterface { return 'mistral' === $provider ? $this->reviewMistral : $this->reviewOpenai; }}The review service swaps its fixed agent for the router, and everything else stays identical: same MessageBag, same response_format, same DTO hydration.
<?php
namespace App\Ai;
use App\Dto\Review;use App\Dto\ReviewedText;use Symfony\AI\Platform\Message\Message;use Symfony\AI\Platform\Message\MessageBag;
class ReviewService{ public function __construct( private readonly ModelRouter $router, ) { }
public function review(string $text, string $provider = ModelRouter::DEFAULT_PROVIDER): ReviewedText { $messages = new MessageBag(Message::ofUser($text));
$result = $this->router->review($provider)->call($messages, [ 'response_format' => Review::class, ]);
return new ReviewedText($result->getContent(), $result->getMetadata()->get('token_usage')); }}Adding a provider now means two lines of YAML, one entry in the router, one <option> in the form. With the other frameworks I tested, I found switching providers more complicated: it touches more code. Here the abstraction sits at the dependency-injection level, so swapping is a config concern, which feels natural in Symfony.
The Profiler Shows Every Prompt, Response, and Tool Call
I have always found the Symfony profiler very useful, and the AI bundle integrates with it nicely. The profiler gets an AI panel: every LLM call appears in the debug toolbar with the full message bag, the model options, the raw response, and the token usage. When a prompt misbehaves, I read exactly what was sent instead of adding dump() calls.

On a coach question about a “you’re / your” mistake, the panel counts 2 platform calls and 1 tool call. Scrolling down shows the JSON schema generated from the #[AsTool] docblocks, and the exact call the model made:

The Test Suite Never Calls OpenAI
The bundle ships an InMemoryPlatform for tests: a platform that runs a closure instead of an HTTP call. The closure receives what the service would have sent, so I assert on prompts as well as on results. A unit test of the review looks like this:
$review = new Review();// ... fill $review with one Mistake
$platform = new InMemoryPlatform(fn () => new ObjectResult($review));
// Routers::fromPlatforms() is a small test helper building the ModelRouter on stub platforms$service = new ReviewService(Routers::fromPlatforms($platform, $platform));$result = $service->review('Je vais à la farmacie.');
$this->assertSame('pharmacie', $result->review->mistakes[0]->correction);Tool calling gets a kernel test: I boot the real container, replace ai.platform.openai with an InMemoryPlatform, and keep the real agents and toolbox from ai.yaml. The stub answers a ToolCallResult on the first call, and I verify that the bundle ran lookup_rule and sent the card back to the model:
$calls = [];$platform = new InMemoryPlatform(function ($model, MessageBag $input) use (&$calls) { $calls[] = $input;
return 1 === \count($calls) ? new ToolCallResult([new ToolCall('call_1', 'lookup_rule', ['ruleId' => 'sa_vs_ca', 'locale' => 'fr'])]) : new TextResult('Remplace par "cela".');});
$answer = $this->service($platform)->answer(new MessageBag(Message::ofUser('sa ou ça ?')));
$this->assertSame('Remplace par "cela".', $answer->text);$this->assertCount(2, $calls); // tool round-trip happenedI Would Use Symfony AI Again
My take after this project: Symfony AI is a real challenger. There is little code to write, and the code that remains reads like regular Symfony: DTOs, services, DI, Twig. The documentation covers the features I used, and the profiler integration is a big plus.
The component is young (I used version 0.12, pre-1.0), so expect API changes. And there is more to explore: streaming responses into a Turbo frame, agent memory, and vision input are still on my list. If one of those lands in a future iteration of this project, it will make a good follow-up article.
Authors
Full-stack web developer at marmelab, Adrien was previously working as an instructor in Alsace. He loves music and plays drums.