In the rapidly evolving landscape of software development, “AI integration” has become a ubiquitous line item. For many PHP developers, this translates to a simple service class that wraps Guzzle or symfony/http-client to call an OpenAI endpoint. This is a useful, but ultimately “dumb,” application. It has no memory, no context beyond what you spoon-feed it, and no ability to act within your application’s domain.
AI agent, by contrast, is a system that can reason, plan, and execute tasks. It maintains state, interacts with a set of “tools” (your services), and orchestrates a multi-step process to achieve a goal. It’s the difference between a simple chatbot and a genuine autonomous assistant.
By leveraging Symfony 7.3’s powerful core components — specifically Messenger, Doctrine, and the Service Container — we can build a robust, stateful, and asynchronous agent from first principles. We will build an agent that uses OpenAI’s “Tool Calling” feature to intelligently interact with our own Symfony services.
In this guide, we will move far beyond the simple API wrapper. We will build a “ReAct” (Reasoning and Acting) agent that can:
- Maintain state and conversation history using Doctrine.
- Run asynchronously using the Symfony Messenger component, preventing HTTP timeouts.
- Use custom “tools” — standard Symfony services — that we make available to the AI.
- Orchestrate a multi-step reasoning loop to answer complex queries.
Our use case: An agent for an e-commerce platform. A user will ask, “What’s the status of my latest order, and are there any shipping delays in my area?”
A simple LLM cannot answer this. It requires two distinct actions:
- Internal Tool: Look up the user’s order in our database.
- External Tool: Search the web for news about shipping delays.
Our agent will orchestrate this entire workflow.
The Architectural Blueprint
Before we write a line of code, we must be architects. A synchronous, in-controller agent is a recipe for disaster. LLM API calls, especially multi-step ones, are slow and unpredictable. A user’s request should not be held hostage by a 30-second curl command.
Our architecture will be event-driven and asynchronous:
- Entrypoint (/api/agent/chat): A Symfony controller receives the user’s message (userInput) and a conversationId.
- Dispatch: The controller does not call the AI. It validates the input and dispatches a single, lightweight message — RunAgentTask — onto the Messenger bus. It immediately returns a 202 Accepted response.
- The “Backbone” (Messenger): A Messenger worker, running in the background (php bin/console messenger:consume async), picks up the RunAgentTask message.
- The “Brain” (AgentOrchestrator): The message handler invokes our core AgentOrchestrator service. This service manages the ReAct loop.
- The “Memory” (Doctrine): The orchestrator loads the conversation’s history from a Conversation entity, managed by Doctrine.
- The “Hands” (Tool Registry): The orchestrator consults a ToolRegistry (a custom service we’ll build) to see what tools (e.g., OrderLookupTool, WebSearchTool) are available.
- The Loop (ReAct + Tool Calling):
- The “Brain” sends the history and tool definitions to the LLM (OpenAI).
- The LLM reasons and responds, “I need to call OrderLookupTool.”
- The “Brain” parses this, calls the actual Symfony service, and gets a result (e.g., “Status: Shipped”).
- The “Brain” appends this result (an “Observation”) to the history and loops, sending it back to the LLM.
- The LLM reasons again: “Great. Now I need to call WebSearchTool for ‘shipping delays’.”
- The “Brain” executes this second tool call.
- The “Brain” loops one last time, sending all context. The LLM now has enough information and generates a final, natural-language answer.
- Persistence: The handler saves the final AI response back to the Conversation entity in the database.
- Retrieval: The user’s frontend polls a separate endpoint (GET /api/agent/conversation/{id}) to retrieve the new messages.
This design is scalable, non-blocking, and leverages the best of Symfony.
## Project Setup & Dependencies
Let’s start with a fresh Symfony project.
# Create the project
symfony new ai-agent-project --webapp
cd ai-agent-project
# Install core dependencies
composer require symfony/messenger
composer require symfony/doctrine-orm-pack
composer require symfony/http-client
# Install the OpenAI client library
composer require openai-php/client
- symfony/messenger: The asynchronous backbone of our application.
- doctrine/doctrine-bundle: The default, robust ORM for Symfony, used for our agent’s memory.
- symfony/http-client: Used by our WebSearchTool to call external APIs.
- openai-php/client: A vital, community-maintained PHP client for the OpenAI API. It provides clean, typed objects for interacting with the chat completions and tool-calling features.
Configuration
First, add your OpenAI API key to .env:
###> openai-php/client ###
OPENAI_API_KEY="sk-..."
###< openai-php/client ###
Next, let’s configure Messenger to have an asynchronous transport called async:
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            # Uncomment this and configure it if you're using a real queue like RabbitMQ
            # async: '%env(MESSENGER_TRANSPORT_DSN)%'
            # For this example, we'll use the in-memory/Doctrine transport.
            # For production, use a real broker.
            async: 'doctrine://default'
        routing:
            # Route our message to the async transport
            'App\Message\RunAgentTask': async
Finally, register the openai-php client as a Symfony service:
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true
    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'
    # Register the OpenAI Client
    OpenAI\Client:
        arguments:
            - '%env(OPENAI_API_KEY)%'
    # Alias for easy injection
    OpenAI\Contracts\ClientContract: '@OpenAI\Client'
Building the Agent’s “Memory” (Doctrine)
The agent’s memory is its conversation history. We need two simple entities.
First, the Conversation, which is the container for a chat session.
// src/Entity/Conversation.php
namespace App\Entity;
use App\Repository\ConversationRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ConversationRepository::class)]
class Conversation
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
    #[ORM\OneToMany(
        mappedBy: 'conversation',
        targetEntity: Message::class,
        cascade: ['persist', 'remove'],
        orphanRemoval: true
    )]
    #[ORM\OrderBy(['createdAt' => 'ASC'])]
    private Collection $messages;
    #[ORM\Column]
    private \DateTimeImmutable $createdAt;
    public function __construct()
    {
        $this->messages = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
    }
    public function getId(): ?int
    {
        return $this->id;
    }
    /**
     * @return Collection<int, Message>
     */
    public function getMessages(): Collection
    {
        return $this->messages;
    }
    public function addMessage(Message $message): static
    {
        if (!$this->messages->contains($message)) {
            $this->messages->add($message);
            $message->setConversation($this);
        }
        return $this;
    }
    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }
}
Second, the Message entity, which stores each turn of the conversation.
// src/Entity/Message.php
namespace App\Entity;
use App\Repository\MessageRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MessageRepository::class)]
class Message
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
    #[ORM\ManyToOne(inversedBy: 'messages')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Conversation $conversation = null;
    /**
     * Role: 'user', 'assistant', or 'tool'
     */
    #[ORM\Column(length: 255)]
    private string $role;
    #[ORM\Column(type: Types::TEXT)]
    private string $content;
    /**
     * For 'tool' roles, this stores the tool call ID.
     */
    #[ORM\Column(length: 255, nullable: true)]
    private ?string $toolCallId = null;
    #[ORM\Column]
    private \DateTimeImmutable $createdAt;
    public function __construct(
        string $role,
        string $content,
        ?string $toolCallId = null
    ) {
        $this->role = $role;
        $this->content = $content;
        $this->toolCallId = $toolCallId;
        $this->createdAt = new \DateTimeImmutable();
    }
    // --- Getters and Setters for all properties ---
    public function getId(): ?int
    {
        return $this->id;
    }
    public function getConversation(): ?Conversation
    {
        return $this->conversation;
    }
    public function setConversation(?Conversation $conversation): static
    {
        $this->conversation = $conversation;
        return $this;
    }
    public function getRole(): string
    {
        return $this->role;
    }
    public function getContent(): string
    {
        return $this->content;
    }
    public function getToolCallId(): ?string
    {
        return $this-o>toolCallId;
    }
    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }
}
The role and toolCallId fields are critical. They map directly to the concepts in the OpenAI API.
- user: A message from the human.
- assistant: A message from the AI (this can be a final answer or a tool-call request).
- tool: The result of a tool execution (an “Observation”). The toolCallId links this result to the assistant’s request.
Run the migrations to create the tables:
php bin/console make:migration
php bin/console doctrine:migrations:migrate
Verification
Run php bin/console doctrine:schema:validate. It should return [OK] The mapping files are correct.
Building the Agent’s “Hands” (Custom Tools)
An agent’s tools are just plain Symfony services. To make this truly decoupled, we will create a custom PHP attribute, #[AsAgentTool], to “tag” our services. The “Brain” will then automatically discover them.
First, define the attribute:
// src/Attribute/AsAgentTool.php
namespace App\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class AsAgentTool
{
    public function __construct(
        public string $name,
        public string $description,
        public array $parameters = []
    ) {
    }
}
This attribute holds the schema that OpenAI’s Tool Calling feature expects.
Next, we need a simple interface that all our tools will implement.
// src/Service/Agent/Tool/AgentToolInterface.php
namespace App\Service\Agent\Tool;
interface AgentToolInterface
{
    /**
     * Executes the tool's logic.
     *
     * @param array $arguments The arguments provided by the LLM.
     * @return string The result of the tool, as a simple string.
     */
    public function __invoke(array $arguments): string;
}
Now, let’s create our two tools.
Tool 1: The Internal Database Tool
We’ll inject a (dummy) OrderRepository for this example.
// src/Service/Agent/Tool/OrderLookupTool.php
namespace App\Service\Agent\Tool;
use App\Attribute\AsAgentTool;
use App\Repository\OrderRepository; // Your real OrderRepository
#[AsAgentTool(
    name: 'getOrderStatus',
    description: 'Retrieves the status and shipping details for a given order ID.',
    parameters: [
        'type' => 'object',
        'properties' => [
            'orderId' => [
                'type' => 'string',
                'description' => 'The unique ID of the order, e.g., "ORD-12345".',
            ],
        ],
        'required' => ['orderId'],
    ]
)]
class OrderLookupTool implements AgentToolInterface
{
    public function __construct(
        // In a real app, inject your OrderRepository
        // private readonly OrderRepository $orderRepository
    ) {
    }
    public function __invoke(array $arguments): string
    {
        $orderId = $arguments['orderId'] ?? null;
        if ($orderId === null) {
            return 'Error: orderId was not provided.';
        }
        // --- Dummy Implementation ---
        // $order = $this->orderRepository->findOneBy(['orderId' => $orderId]);
        // if (!$order) {
        //     return "Error: Order with ID '{$orderId}' not found.";
        // }
        // return json_encode([
        //     'status' => $order->getStatus(),
        //     'estimatedDelivery' => $order->getEstimatedDelivery()->format('Y-m-d')
        // ]);
        // --- End Dummy ---
        // For this article's test:
        if ($orderId === 'ORD-12345') {
             return json_encode([
                'status' => 'Shipped',
                'carrier' => 'FedEx',
                'trackingNumber' => 'FX-98765',
                'estimatedDelivery' => '2025-10-30',
                'shippingAddress' => '123 Main St, Anytown, USA'
            ]);
        }
        return "Error: Order with ID '{$orderId}' not found.";
    }
}
Tool 2: The External Web Search Tool
This tool uses symfony/http-client to call a (fictional) third-party search API.
// src/Service/Agent/Tool/WebSearchTool.php
namespace App\Service\Agent\Tool;
use App\Attribute\AsAgentTool;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsAgentTool(
    name: 'searchWeb',
    description: 'Searches the web for recent news or information about a query. 
                  Useful for finding real-time information like shipping delays.',
    parameters: [
        'type' => 'object',
        'properties' => [
            'query' => [
                'type' => 'string',
                'description' => 'The search query, e.g., "shipping delays Anytown, USA".',
            ],
        ],
        'required' => ['query'],
    ]
)]
class WebSearchTool implements AgentToolInterface
{
    public function __construct(
        private readonly HttpClientInterface $client
    ) {
    }
    public function __invoke(array $arguments): string
    {
        $query = $arguments['query'] ?? null;
        if ($query === null) {
            return 'Error: query was not provided.';
        }
        // --- Dummy Implementation ---
        // In a real app, you would call Brave Search, SerpApi, etc.
        // $response = $this->client->request('GET', 'https://api.search.com/search', [
        //     'query' => ['q' => $query, 'apiKey' => '...']
        // ]);
        // $results = $response->toArray();
        // return json_encode($results['snippets']);
        // --- End Dummy ---
        // For this article's test:
        if (str_contains(strtolower($query), 'anytown')) {
            return json_encode([
                ['source' => 'localnews.com', 'snippet' => 'A major storm has caused significant 
                                                            shipping delays in Anytown, USA.'],
                ['source' => 'fedex.com/alerts', 'snippet' => 'FedEx operations in the Anytown
                                                            region are suspended.']
            ]);
        }
        return 'No relevant web results found.';
    }
}
The “Symfony-Way” Tool Registry
How does our “Brain” find these tools? We use Symfony’s ServiceLocator.
// src/Service/Agent/ToolRegistry.php
namespace App\Service\Agent;
use App\Attribute\AsAgentTool;
use App\Service\Agent\Tool\AgentToolInterface;
use Psr\Container\ContainerInterface;
class ToolRegistry
{
    /** @var array<string, AsAgentTool> */
    private array $toolSchemas = [];
    /** @var array<string, AgentToolInterface> */
    private array $tools = [];
    public function __construct(
        // This is a magic Symfony service!
        // It's a locator that finds all services tagged with 'app.agent_tool'
        private readonly ContainerInterface $toolLocator
    ) {
        // On boot, we iterate over all tagged services
        foreach ($this->toolLocator->getProvidedServices() as $serviceId) {
            $reflectionClass = new \ReflectionClass($serviceId);
            $attributes = $reflectionClass->getAttributes(AsAgentTool::class);
            if (empty($attributes)) {
                continue;
            }
            /** @var AsAgentTool $schema */
            $schema = $attributes[0]->newInstance();
            $this->toolSchemas[$schema->name] = $schema;
        }
    }
    /**
     * Gets the JSON schema for all available tools, formatted for the OpenAI API.
     */
    public functiongetToolSchemas(): array
    {
        $schemas = [];
        foreach ($this->toolSchemas as $schema) {
            $schemas[] = [
                'type' => 'function',
                'function' => [
                    'name' => $schema->name,
                    'description' => $schema->description,
                    'parameters' => $schema->parameters,
                ],
            ];
        }
        return $schemas;
    }
    /**
     * Checks if a tool exists by name.
     */
    public function hasTool(string $name): bool
    {
        return isset($this->toolSchemas[$name]);
    }
    /**
     * Calls a tool by its name with the given arguments.
     */
    public function callTool(string $name, array $arguments): string
    {
        if (!$this->hasTool($name)) {
            return "Error: Tool '{$name}' not found.";
        }
        // Lazily load the tool from the service locator only when it's called
        if (!isset($this->tools[$name])) {
            $serviceId = $this->getServiceIdForTool($name);
            $this->tools[$name] = $this->toolLocator->get($serviceId);
        }
        // Invoke the tool
        try {
            return ($this->tools[$name])($arguments);
        } catch (\Exception $e) {
            return "Error executing tool '{$name}': " . $e->getMessage();
        }
    }
    private function getServiceIdForTool(string $toolName): ?string
    {
        foreach ($this->toolSchemas as $name => $schema) {
            if ($name === $toolName) {
                // This relies on a convention: the tool name in the attribute
                // must map to a service ID.
                // We need to find the service ID for the schema.
                foreach ($this->toolLocator->getProvidedServices() as $serviceId) {
                    $reflectionClass = new \ReflectionClass($serviceId);
                    $attributes = $reflectionClass->getAttributes(AsAgentTool::class);
                    if (empty($attributes)) continue;
                    if ($attributes[0]->newInstance()->name === $toolName) {
                        return $serviceId;
                    }
                }
            }
        }
        return null;
    }
}
This is complex, but incredibly powerful. It uses reflection once on boot to build a map of schemas. Then, it lazily-loads the actual tool service only when the AI decides to call it.
To make this work, we must “tag” our tools in config/services.yaml:
# config/services.yaml
services:
    # ... other services
    App\Service\Agent\ToolRegistry:
        arguments:
            # This wires up the ServiceLocator
            - !tagged_locator 'app.agent_tool'
    # This tags all classes that implement our interface
    App\Service\Agent\Tool\AgentToolInterface:
        tags: ['app.agent_tool']
Now, any new AgentToolInterface class we create with the #[AsAgentTool] attribute will be automatically discovered by our agent.
Building the “Brain” (The Orchestrator)
This is the core of our agent. The AgentOrchestrator service will manage the ReAct loop using OpenAI’s Tool Calling feature.
// src/Service/Agent/AgentOrchestrator.php
namespace App\Service\Agent;
use App\Entity\Conversation;
use App\Entity\Message;
use App\Service\Agent\ToolRegistry;
use Doctrine\ORM\EntityManagerInterface;
use OpenAI\Client as OpenAIClient;
use OpenAI\DataObjects\Chat\Message as OpenAiMessage;
use OpenAI\DataObjects\Chat\Response;
use Psr\Log\LoggerInterface;
class AgentOrchestrator
{
    private const MAX_LOOP_ITERATIONS = 5;
    private const AGENT_MODEL = 'gpt-4-turbo'; // Use a model that supports tool calling
    public function __construct(
        private readonly OpenAIClient $client,
        private readonly ToolRegistry $toolRegistry,
        private readonly EntityManagerInterface $em,
        private readonly LoggerInterface $logger
    ) {
    }
    /**
     * Runs the agent loop for a given conversation and user input.
     */
    public run(Conversation $conversation, string $userInput): void
    {
        // 1. Add the new user message to the conversation
        $userMessage = new Message('user', $userInput);
        $conversation->addMessage($userMessage);
        $this->em->flush();
        // 2. Format the entire history for the OpenAI API
        $messageHistory = $this->formatMessageHistory($conversation);
        // 3. Get the tool schemas from our registry
        $tools = $this->toolRegistry->getToolSchemas();
        $iteration = 0;
        while ($iteration < self::MAX_LOOP_ITERATIONS) {
            $iteration++;
            $this->logger->info(
                "Agent Loop (Iteration {$iteration}): Calling OpenAI with " 
                . count($messageHistory) . " messages."
            );
            // 4. Call the OpenAI API
            $response = $this->client->chat()->create([
                'model' => self::AGENT_MODEL,
                'messages' => $messageHistory,
                'tools' => $tools,
                'tool_choice' => 'auto', // Let the model decide
            ]);
            $responseMessage = $response->choices[0]->message;
            // 5. Check if the AI wants to call a tool
            if (!empty($responseMessage->toolCalls)) {
                $this->logger->info("Agent Loop: AI requested tool calls.");
                // Add the AI's tool-call request to history
                $assistantMessage = $this->persistAssistantMessage($conversation, $responseMessage);
                $messageHistory[] = $assistantMessage->toArray();
                // 6. Execute the requested tools
                $toolOutputs = $this->executeToolCalls(
                    $responseMessage->toolCalls
                );
                // 7. Add tool *results* to history and persist them
                foreach ($toolOutputs as $output) {
                    $toolMessage = new Message(
                        'tool',
                        $output['content'],
                        $output['tool_call_id']
                    );
                    $conversation->addMessage($toolMessage);
                    $messageHistory[] = $toolMessage->toArray();
                }
                $this->em->flush();
                // 8. Loop again: The loop will re-run with the tool results in history
                continue;
            }
            // 9. No tool calls. This is the final answer.
            $this->logger->info("Agent Loop: AI provided final answer.");
            $finalAnswer = $responseMessage->content ?? 'I am sorry, I could not process that.';
            $agentMessage = new Message('assistant', $finalAnswer);
            $conversation->addMessage($agentMessage);
            $this->em->flush();
            // Break the loop
            return;
        }
        // Safety break
        $this->logger->warning("Agent Loop: Max iterations reached for conversation {$conversation->getId()}");
        $errorMessage = new Message('assistant', 'Sorry, I got stuck in a loop and could not complete your request.');
        $conversation->addMessage($errorMessage);
        $this->em->flush();
    }
    /**
     * Executes the tool calls requested by the AI.
     */
    private function executeToolCalls(array $toolCalls): array
    {
        $toolOutputs = [];
        foreach ($toolCalls as $toolCall) {
            $name = $toolCall->function->name;
            $arguments = json_decode($toolCall->function->arguments, true);
            $this->logger->info("Agent Loop: Executing tool '{$name}'", $arguments);
            $result = $this->toolRegistry->callTool($name, $arguments);
            $toolOutputs[] = [
                'role' => 'tool',
                'content' => $result,
                'tool_call_id' => $toolCall->id,
            ];
        }
        return $toolOutputs;
    }
    /**
     * Formats our Doctrine entities into the array format OpenAI expects.
     */
    private function formatMessageHistory(Conversation $conversation): array
    {
        $history = [];
        // Add a system prompt
        $history[] = [
            'role' => 'system',
            'content' => 'You are a helpful e-commerce assistant.
                          You will answer user questions by first using the
                          provided tools, then summarizing the results.
                          The user\'s latest order ID is ORD-12345.
                          The user\'s current location is Anytown, USA.'
        ];
        foreach ($conversation->getMessages() as $message) {
            $entry = [
                'role' => $message->getRole(),
                'content' => $message->getContent(),
            ];
            // Add tool call info if it's an assistant's tool-call message
            if ($message->getRole() === 'assistant' && $message->getContent() === null) {
                 // This part needs refinement based on how we store tool calls.
                 // Let's assume a "tool call" message from the assistant is
                 // stored in a specific way.
                 // For simplicity, we'll re-build from the OpenAiMessage object.
                 // See persistAssistantMessage
            }
            if ($message->getRole() === 'tool') {
                $entry['tool_call_id'] = $message->getToolCallId();
            }
            $history[] = $entry;
        }
        // This is a simplification. A robust implementation would rebuild
        // the `tool_calls` array for assistant messages.
        // For our loop, we are appending live, so this is fine.
        // Let's re-format from Doctrine entities correctly.
        $formattedHistory = [
            [
                'role' => 'system',
                'content' => 'You are a helpful e-commerce assistant. 
                              You will answer user questions by first using the 
                              provided tools, then summarizing the results. 
                              The user\'s latest order ID is ORD-12345. 
                              The user\'s current location is Anytown, USA.'
            ]
        ];
        foreach ($conversation->getMessages() as $message) {
             $formattedHistory[] = $message->toArray(); // Assumes a toArray() method
        }
        // Let's create the toArray() on the Message entity
        // Add to src/Entity/Message.php:
        /*
        public function toArray(): array
        {
            $data = [
                'role' => $this->role,
                'content' => $this->content,
            ];
            // This is complex. The OpenAI API expects:
            // assistant: { role: "assistant", tool_calls: [...] }
            // tool: { role: "tool", tool_call_id: "...", content: "..." }
            // Our current loop logic builds the history array in-memory,
            // which is much simpler. Let's stick to that.
            // The `formatMessageHistory` function is the correct approach.
        }
        */
        // Re-writing `formatMessageHistory` for robustness
        $messageHistory = [
             [
                'role' => 'system',
                'content' => 'You are a helpful e-commerce assistant. 
                              You will answer user questions by first using the 
                              provided tools, then summarizing the results.
                              The user\'s latest order ID is ORD-12345. 
                              The user\'s current location is Anytown, USA.'
            ]
        ];
        $assistantToolCalls = [];
        foreach ($conversation->getMessages() as $msg) {
            if ($msg->getRole() === 'user') {
                $messageHistory[] = ['role' => 'user', 'content' => $msg->getContent()];
            } elseif ($msg->getRole() === 'assistant') {
                // If it's a tool-call message (content is null, has tool calls)
                if ($msg->getContent() === null && !empty($msg->getToolCalls())) { // We need to add getToolCalls() to Message
                    // This is getting too complex for this example.
                    // We will simplify: our `persistAssistantMessage` will
                    // store the raw tool call JSON.
                    // Let's restart the orchestrator with a simpler history builder.
                    // This is a common, hard problem in agent design.
                    // --- SIMPLIFIED `formatMessageHistory` ---
                     $messageHistory = [
                         [
                            'role' => 'system',
                            'content' => 'You are a helpful e-commerce assistant. 
                                          You will answer user questions by first using the 
                                          provided tools, then summarizing the results.
                                          The user\'s latest order ID is ORD-12345. 
                                          The user\'s current location is Anytown, USA.'
                        ]
                    ];
                    foreach ($conversation->getMessages() as $message) {
                        $msg = [
                            'role' => $message->getRole(), 
                            'content' => $message->getContent()
                        ];
                        // This assumes our persisted "assistant" message for
                        // tool calls is stored with its `tool_calls`
                        // (We will adjust `persistAssistantMessage` to do this)
                        if ($message->getRole() === 'assistant' && $message->getToolCallsData()) {
                             $msg['tool_calls'] = $message->getToolCallsData();
                             // And content will be null
                             $msg['content'] = null;
                        }
                        if ($message->getRole() === 'tool') {
                            $msg['tool_call_id'] = $message->getToolCallId();
                        }
                        $messageHistory[] = $msg;
                    }
                    return $messageHistory;
                    // --- END SIMPLIFIED ---
                    // This requires adding `toolCallsData` (json, nullable) to Message.php
            }
        }
    }
    // Add `toolCallsData` (json, nullable) to `Message.php` and re-run migrations.
    private function persistAssistantMessage(Conversation $conversation, OpenAiMessage $responseMessage): Message
    {
        // Store the raw tool_calls array
        $toolCallsData = array_map(fn($call) => [
            'id' => $call->id,
            'type' => $call->type,
            'function' => [
                'name' => $call->function->name,
                'arguments' => $call->function->arguments,
            ]
        ], $responseMessage->toolCalls);
        // Create an "assistant" message with NULL content but with tool calls
        $message = new Message('assistant', null);
        $message->setToolCallsData($toolCallsData); // Add this setter to Message.php
        $conversation->addMessage($message);
        $this->em->flush();
        return $message;
    }
}
This part is complex. We had to modify our Message entity to store the toolCallsData as a JSON array. This is crucial for correctly rebuilding the conversation history on subsequent runs.
Connecting the “Backbone” (Messenger & Controller)
Now we just need to wire everything together.
The Message: This is our simple DTO (Data Transfer Object).
// src/Message/RunAgentTask.php
namespace App\Message;
class RunAgentTask
{
    public function __construct(
        public readonly int $conversationId,
        public readonly string $userInput
    ) {
    }
}
The Handler: This is the worker that consumes the message and calls our “Brain.”
// src/MessageHandler/RunAgentTaskHandler.php
namespace App\MessageHandler;
use App\Message\RunAgentTask;
use App\Repository\ConversationRepository;
use App\Service\Agent\AgentOrchestrator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class RunAgentTaskHandler
{
    public function __construct(
        private readonly AgentOrchestrator $orchestrator,
        private readonly ConversationRepository $conversationRepository,
        private readonly LoggerInterface $logger
    ) {
    }
    public function __invoke(RunAgentTask $message): void
    {
        $conversation = $this->conversationRepository->find($message->conversationId);
        if (!$conversation) {
            $this->logger->error(
                "Agent task failed: Conversation {$message->conversationId} not found."
            );
            return;
        }
        try {
            $this->orchestrator->run($conversation, $message->userInput);
        } catch (\Exception $e) {
            $this->logger->critical(
                "Agent orchestrator failed: " . $e->getMessage(),
                ['exception' => $e, 'conversation' => $message->conversationId]
            );
            // Optionally, add a "failed" message back to the conversation
        }
    }
}
The Controller (The Entrypoint): Finally, the two API endpoints for the frontend.
// src/Controller/AgentController.php
namespace App\Controller;
use App\Entity\Conversation;
use App\Message\RunAgentTask;
use App\Repository\ConversationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
#[Route('/api/agent')]
class AgentController extends AbstractController
{
    /**
     * Creates a new conversation or posts a message to an existing one.
     */
    #[Route('/chat', name: 'agent_chat_post', methods: ['POST'])]
    public function chat(
        Request $request,
        MessageBusInterface $bus,
        EntityManagerInterface $em,
        ConversationRepository $convoRepo
    ): JsonResponse {
        $data = $request->toArray();
        $userInput = $data['message'] ?? null;
        $conversationId = $data['conversationId'] ?? null;
        if (empty($userInput)) {
            return $this->json(['error' => 'message is required'], Response::HTTP_BAD_REQUEST);
        }
        if ($conversationId) {
            $conversation = $convoRepo->find($conversationId);
            if (!$conversation) {
                return $this->json(
                    ['error' => 'Conversation not found'],
                    Response::HTTP_NOT_FOUND
                );
            }
        } else {
            $conversation = new Conversation();
            $em->persist($conversation);
            // We must flush here to get an ID
            $em->flush();
        }
        // Dispatch the task to the background
        $bus->dispatch(new RunAgentTask($conversation->getId(), $userInput));
        // Return an immediate 202 Accepted
        return $this->json(
            ['status' => 'Task accepted', 'conversationId' => $conversation->getId()],
            Response::HTTP_ACCEPTED
        );
    }
    /**
     * Polls for conversation updates.
     */
    #[Route('/conversation/{id}', name: 'agent_conversation_get', methods: ['GET'])]
    public function getConversation(
        Conversation $conversation,
        SerializerInterface $serializer
    ): JsonResponse {
        // Use serialization groups to avoid circular references
        $json = $serializer->serialize($conversation, 'json', [
            'groups' => ['conversation:read']
        ]);
        // Don't forget to add `#[Groups(['conversation:read'])]`
        // to the properties in your Conversation and Message entities.
        return new JsonResponse($json, 200, [], true);
    }
}
Verification and Testing
We’re ready to test the complete flow.
Step 1: Run your migrations (if you added the new toolCallsData column).
php bin/console make:migration
php bin/console doctrine:migrations:migrate
Step 2: Start the Messenger worker in a terminal. The -vv flag is essential to see the logs.
php bin/console messenger:consume async -vv
Step 3: In a second terminal, send a request to create a conversation and ask our complex question.
curl -X POST 'http://127.0.0.1:8000/api/agent/chat' \
-H 'Content-Type: application/json' \
-d '{
    "message": "What is the status of my latest order, and are there any shipping delays in my area?"
}'
You should get an immediate 202 Accepted response with a conversationId:
{
    "status": "Task accepted",
    "conversationId": 1
}
Step 4: Watch your Messenger terminal. This is where the magic happens. You will see a stream of logs:
- [Messenger] Received message App\Message\RunAgentTask
- [Info] Agent Loop (Iteration 1): Calling OpenAI with 2 messages. (System + User)
- [Info] Agent Loop: AI requested tool calls.
- [Info] Agent Loop: Executing tool ‘getOrderStatus’ (with args {“orderId”:”ORD-12345"})
- [Info] Agent Loop (Iteration 2): Calling OpenAI with 4 messages. (System, User, Assistant-Tool-Call, Tool-Result)
- [Info] Agent Loop: AI requested tool calls.
- [Info] Agent Loop: Executing tool ‘searchWeb’ (with args {“query”:”shipping delays Anytown, USA”})
- [Info] Agent Loop (Iteration 3): Calling OpenAI with 6 messages.
- [Info] Agent Loop: AI provided final answer.
- [Messenger] Message App\Message\RunAgentTask handled successfully.
Step 5: Poll the GET endpoint to retrieve the final result.
curl -X GET 'http://127.0.0.1:8000/api/agent/conversation/1'
You will get the full conversation history, including the final AI-generated summary:
{
    "id": 1,
    "messages": [
        {
            "role": "user",
            "content": "What is the status of my latest order, and are there any shipping delays in my area?"
        },
        // ... intermediate tool/assistant messages ...
        {
            "role": "assistant",
            "content": "I've checked on your order (ORD-12345), and it has already shipped via FedEx (tracking: FX-98765). 
                        However, please be aware that a major storm in Anytown, USA, has suspended 
                        FedEx operations, so you should expect significant delays on the 
                        original estimated delivery date of 2025-10-30."
        }
    ]
}
Conclusion
We have successfully built a AI agent. This system is a world away from a simple API wrapper.
It is asynchronous and scalable, built on Symfony Messenger.
It has persistent memory, thanks to Doctrine.
It is extensible, using a custom #[AsAgentTool] attribute and Symfony’s service locator to automatically discover and integrate new capabilities.
It reasons and acts, orchestrating a complex, multi-step tool-calling loop from first principles.
By building the orchestration logic ourselves, we have gained maximum control and a system that is perfectly idiomatic to the Symfony framework.
In the near future, the symfony/ai-platform and symfony/ai-agent components will transition to a stable stage with all the additional benefits, and we will be able to use the #[AsTool] attribute out of the box, which will significantly simplify the development process for similar applications.
This is the real power of PHP in the modern AI-driven world: not just serving content, but serving as the robust, reliable “brain” that orchestrates complex, intelligent workflows.
I’d love to hear your thoughts in comments!
Stay tuned — and let’s keep the conversation going.
 
 
              
 
    
Top comments (0)