Build a stateful, asynchronous ReAct AI agent in PHP using Symfony, Doctrine, Messenger, and OpenAI tool calling—production patterns and code.Build a stateful, asynchronous ReAct AI agent in PHP using Symfony, Doctrine, Messenger, and OpenAI tool calling—production patterns and code.

How to Build an Asynchronous ReAct Agent in PHP with Symfony, Doctrine, and OpenAI

2025/11/03 13:04

\ 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:

  1. Maintain state and conversation history using Doctrine.
  2. Run asynchronously using the Symfony Messenger component, preventing HTTP timeouts.
  3. Use custom “tools” — standard Symfony services — that we make available to the AI.
  4. 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:

  1. Internal Tool: Look up the user’s order in our database.
  2. 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:

  1. Entrypoint (/api/agent/chat): A Symfony controller receives the user’s message (userInput) and a conversationId.
  2. 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.
  3. The “Backbone” (Messenger): A Messenger worker, running in the background (php bin/console messenger:consume async), picks up the RunAgentTask message.
  4. The “Brain” (AgentOrchestrator): The message handler invokes our core AgentOrchestrator service. This service manages the ReAct loop.
  5. The “Memory” (Doctrine): The orchestrator loads the conversation’s history from a Conversation entity, managed by Doctrine.
  6. 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.
  7. 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.

8. Persistence: The handler saves the final AI response back to the Conversation entity in the database.

9. 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:

  1. [Messenger] Received message App\Message\RunAgentTask
  2. [Info] Agent Loop (Iteration 1): Calling OpenAI with 2 messages. (System + User)
  3. [Info] Agent Loop: AI requested tool calls.
  4. [Info] Agent Loop: Executing tool ‘getOrderStatus’ (with args {“orderId”:”ORD-12345"})
  5. [Info] Agent Loop (Iteration 2): Calling OpenAI with 4 messages. (System, User, Assistant-Tool-Call, Tool-Result)
  6. [Info] Agent Loop: AI requested tool calls.
  7. [Info] Agent Loop: Executing tool ‘searchWeb’ (with args {“query”:”shipping delays Anytown, USA”})
  8. [Info] Agent Loop (Iteration 3): Calling OpenAI with 6 messages.
  9. [Info] Agent Loop: AI provided final answer.
  10. [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.

\

Disclaimer: The articles reposted on this site are sourced from public platforms and are provided for informational purposes only. They do not necessarily reflect the views of MEXC. All rights remain with the original authors. If you believe any content infringes on third-party rights, please contact [email protected] for removal. MEXC makes no guarantees regarding the accuracy, completeness, or timeliness of the content and is not responsible for any actions taken based on the information provided. The content does not constitute financial, legal, or other professional advice, nor should it be considered a recommendation or endorsement by MEXC.
Share Insights

You May Also Like

Astonishing Kevin Durant Bitcoin Fortune: A Decade-Long Hold Yields 195-Fold Return

Astonishing Kevin Durant Bitcoin Fortune: A Decade-Long Hold Yields 195-Fold Return

BitcoinWorld Astonishing Kevin Durant Bitcoin Fortune: A Decade-Long Hold Yields 195-Fold Return Imagine logging into an old account and discovering a fortune! That’s exactly what happened to NBA superstar Kevin Durant. His decade-old, forgotten Coinbase account, which held an early Kevin Durant Bitcoin investment, has now resurfaced, revealing an incredible 195-fold return. This remarkable story highlights the immense potential of long-term cryptocurrency holdings and serves as a fascinating example for anyone interested in digital assets. The Accidental ‘Hodl’: How Kevin Durant’s Bitcoin Investment Skyrocketed The journey of Kevin Durant’s Bitcoin investment began in 2016. He encountered Bitcoin, then priced at a modest $600, during a birthday celebration for venture capitalist Ben Horowitz. Intrigued, Durant decided to invest, setting up a Coinbase account. However, as many early adopters can attest, managing digital assets in the nascent crypto landscape wasn’t always straightforward. Durant subsequently misplaced his Coinbase login credentials, leading to an involuntary long-term hold – a phenomenon affectionately known as "HODL" (Hold On for Dear Life) in the crypto community. This accidental strategy proved to be a stroke of pure luck. After a decade, with assistance from Coinbase and a thorough identity verification process, Durant successfully recovered his account. While the exact amount of BTC remains undisclosed, the outcome is clear: a staggering 195-fold return on his initial investment. Initial Investment: Bitcoin at $600 in 2016. Accidental Strategy: Lost login details led to an unintentional "HODL." Recovery: Coinbase assisted with identity verification. Return: A remarkable 195-fold increase in value. Beyond Personal Gains: Kevin Durant’s Broader Crypto Engagement This isn’t Kevin Durant’s first foray into the world of digital assets, nor is it his only connection to the industry. Long before this incredible recovery, Durant had already demonstrated a positive and forward-thinking stance toward cryptocurrency. His engagement extends beyond just holding assets; he has actively participated in the crypto ecosystem. Durant previously partnered with Coinbase, one of the leading cryptocurrency exchanges, showcasing his belief in the platform and the broader potential of digital currencies. He has also ventured into the realm of Non-Fungible Tokens (NFTs), purchasing digital collectibles and exploring this evolving sector. These actions underscore his understanding and acceptance of crypto’s growing influence. His continued involvement helps bridge the gap between mainstream culture and the crypto world, bringing increased visibility and legitimacy to digital assets. The story of his Kevin Durant Bitcoin recovery only adds another layer to his impressive crypto narrative, inspiring many to consider the long-term prospects of digital investments. Valuable Lessons from Kevin Durant’s Bitcoin Journey Kevin Durant’s story offers compelling insights for both seasoned investors and newcomers to the crypto space. It powerfully illustrates the potential rewards of a patient, long-term investment approach, even if accidental. While not everyone will forget their login details for a decade, the principle of "HODLing" through market volatility can yield significant returns. However, it also subtly highlights the importance of proper security and record-keeping. Losing access to an account, even if eventually recovered, can be a stressful experience. Here are some actionable takeaways: Embrace Long-Term Vision: Bitcoin’s history shows substantial growth over extended periods. Patience often outperforms short-term trading. Secure Your Assets: Always keep your login details, seed phrases, and recovery information in multiple, secure locations. Consider hardware wallets for significant holdings. Understand the Volatility: Crypto markets are volatile. Investing only what you can afford to lose and being prepared for price swings is crucial. Stay Informed: While Durant’s hold was accidental, continuous learning about the crypto market can help make informed decisions. His experience reinforces the idea that strategic, even if involuntary, patience can be profoundly rewarding in the world of cryptocurrency. The Kevin Durant Bitcoin story is a testament to this. The tale of Kevin Durant’s forgotten Coinbase account and his astonishing 195-fold return on a decade-old Bitcoin investment is nothing short of extraordinary. It’s a vivid reminder of the transformative power of early adoption and the incredible growth potential within the cryptocurrency market. Beyond the personal windfall, Durant’s continued engagement with crypto, from partnerships to NFTs, reinforces his role as a prominent figure in the digital asset space. His accidental "HODL" has become a legendary example, inspiring many to look at long-term crypto investments with renewed optimism and a keen eye on future possibilities. Frequently Asked Questions About Kevin Durant’s Bitcoin Investment Here are some common questions regarding Kevin Durant’s recent crypto revelation: Q: How much did Kevin Durant initially invest in Bitcoin?A: The exact amount of Bitcoin Kevin Durant initially invested has not been disclosed. However, it was purchased around 2016 when Bitcoin was priced at approximately $600. Q: How did Kevin Durant recover his forgotten Coinbase account?A: Coinbase assisted Kevin Durant in recovering his account after he completed a thorough identity verification process, confirming his ownership of the decade-old account. Q: What does "195-fold return" mean?A: A "195-fold return" means that the value of his initial investment multiplied by 195 times. If he invested $1,000, it would now be worth $195,000. Q: Has Kevin Durant invested in other cryptocurrencies or NFTs?A: Yes, Kevin Durant has shown a friendly stance toward cryptocurrency beyond Bitcoin. He has partnered with Coinbase and has also purchased Non-Fungible Tokens (NFTs) in the past. Q: Is Kevin Durant’s story typical for Bitcoin investors?A: While the 195-fold return is exceptional, the principle of significant gains from long-term holding (HODLing) is a common theme in Bitcoin’s history. However, not all investments yield such high returns, and market volatility is always a factor. Did Kevin Durant’s incredible crypto journey inspire you? Share this astonishing story with your friends and followers on social media to spark conversations about the future of digital assets and the power of long-term investing! Your shares help us bring more fascinating crypto news to a wider audience. To learn more about the latest Bitcoin trends, explore our article on key developments shaping Bitcoin’s institutional adoption. This post Astonishing Kevin Durant Bitcoin Fortune: A Decade-Long Hold Yields 195-Fold Return first appeared on BitcoinWorld.
Share
Coinstats2025/09/19 18:45