This content originally appeared on HackerNoon and was authored by MattLeads
Artificial intelligence is no longer a futuristic concept — it has become a practical cornerstone of modern business and technology. Across industries, leaders are rethinking workflows, productivity, and even strategy with the help of AI-driven tools. Yet while the market is filled with off-the-shelf solutions, there’s enormous value in understanding how to build and adapt these tools yourself.
In this article, I’ll walk you through how to create your own AI agent in just 11 minutes using Symfony. The approach is simple, flexible, and compatible with leading LLMs such as OpenAI, Gemini, Anthropic’s Claude, and Meta’s Llama — giving you a foundation you can tailor to your unique organizational needs.
Let’s look at a simple use case: receiving emails and automatically generating a summary that’s then sent to a separate email or messenger. Of course, this concept can be extended to other data sources like social media, messaging apps, or files from your local drive or cloud storage.
For this guide, we’ll implement a straightforward example using the IMAP protocol to access emails as our data source. Our notification channel will be a dedicated email address where the summary is sent. This channel can be easily swapped out for a popular messenger like Slack, Twitter, LinkedIn, Discord etc.
For this App, we’ll be using the Symfony Framework and its standard components.
Symfony is a modular PHP framework known for its predictable development cycle, long-term support (LTS), and highly optimized architecture. It boasts a global community of developers and some of the best official documentation available. Symfony is a versatile tool used in projects of all sizes and complexities, from small prototypes and REST APIs to large-scale enterprise applications like ERP, CRM, e-Commerce, and BPMN systems.
Let’s begin by creating a new Symfony application.
\
composer create-project symfony/skeleton AIAgentApp
The Symfony AI Agent Component
To get started, we’ll need the Symfony AiAgent Component. The first release of this component was in July 2025. While the documentation is still sparse, it won’t stop us from using it to build something powerful.
Symfony AIAgent component is a framework for building AI agents that can interact with users, perform tasks, and manage workflows within a Symfony application. It’s part of the new Symfony AI initiative, which aims to natively integrate AI capabilities into PHP applications.
In order to install components that are currently under development, you’ll need to configure Composer.json to allow for pre-release versions while still preferring stable ones when available.
To do this, add two lines to the root configuration of your composer.json file:
\
"minimum-stability": "dev",
"prefer-stable": true,
“minimum-stability”: “dev” — This setting tells Composer that the minimum stability level for the packages you install is dev (development). Without this, Composer would ignore any unstable package versions, preventing you from installing new components like AiAgentComponent.
“prefer-stable”: true — This parameter instructs Composer to choose the stable version of a package if it’s available. This is a crucial safety measure that ensures you use reliable releases whenever possible, even with dev versions enabled.
Once you’ve saved these changes, you can install the AiAgentComponent with the following command:
\
composer require symfony/ai-agent
Connecting to the OpenAI API
For this App, we’ll be using OpenAI’s powerful API. Before you can connect, you’ll need to obtain an API key from your OpenAI account.
Once you have your key, store it securely in your project’s .env file. Define it as an environment variable named OPENAIAPIKEY.
\
OPENAI_API_KEY=your_api_key
Finally, to make this key accessible to our application, we’ll bind it in the services.yaml configuration file. This crucial step uses Symfony’s dependency injection to ensure our AI agent can authenticate with the OpenAI API.
\
services:
_defaults:
autowire: true
autoconfigure: true
bind:
'$openaiApiKey': '%env(OPENAI_API_KEY)%'
Setting Up Your IMAP Connection
To access our email data, we’ll rely on the IMAP mail client, a standard PHP library. This requires adding a dependency to your composer.json file. Include the following line in the require section:
\
"require": {
"ext-imap": "*",
...
After you’ve updated the file, run composer update to install the extension.
We’ll add three environment variables to our .env file to connect to the mailbox from which we’ll fetch emails for processing with the LLM model:
\
IMAP_HOST=imap.mailprovider.com:993/imap/ssl
IMAP_USERNAME=mailbox@mailprovider.com
IMAP_PASSWORD=mailboxPassword
Stripping HTML for Optimal Performance
Emails often arrive in HTML format. Sending all of that code to a large language model (LLM) would be inefficient and waste valuable resources and tokens.
To optimize this process, we’ll use the Symfony Mime component to strip out all HTML tags, ensuring our LLM receives only clean, plain-text content for analysis. This not only saves you money but also improves the quality and speed of the AI agent’s responses.
To get started, install the component with a simple Composer command:
\
composer require symfony/mime
Sending Notifications with Symfony Notifier
To send our summarized emails, we’ll use the powerful Symfony Notifier Component. This component provides a clean, unified way to send notifications across different channels (like email, SMS or messenger apps) with minimal effort.
To get started, install the component:
\
composer require symfony/notifier
For this example, we’ll keep the config/packages/notifier.yaml file simple and focused on the email channel to avoid over-complicating things.
\
framework:
notifier:
chatter_transports:
texter_transports:
channel_policy:
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
Integrating Email with Symfony Mailer
Since we’ve chosen email as our primary notification channel, we also need the Symfony Mailer Component. This component works seamlessly with Notifier to handle the actual delivery of our messages.
For this guide, our notifications will be sent as plain text, so we don’t need a templating engine. We can save that for a future article, where I’ll show you how to easily set up email templates to add more polish and flexibility to your agent’s notifications.
\
composer require symfony/mailer
To configure Symfony Mailer, you’ll need to set the DSN (Data Source Name) in your .env file. This tells Symfony how to connect to your email provider’s service.
Configuring the Mailer DSN
First, ensure your config/packages/mailer.yaml file is correctly set up to use an environment variable:
\
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
envelope:
sender: 'sender@mailbox.com'
The dsn: ‘%env(MAILERDSN)% line instructs Symfony to look for the MAILERDSN variable in your environment. The envelope key is a simple way to set a global sender for all emails, but this can be overridden later.
Next, you need to add the MAILER_DSN variable to your .env file. The format of the DSN depends on your email provider.
\
MAILER_DSN=smtp://username@yourmailbox.com:password@smtp.yourmailboxprovider.com:465/?encryption=ssl
Here are some common examples:
For Gmail (using SMTP):
MAILER_DSN=smtp://username@gmail.com:password@smtp.gmail.com:587
Note: For Gmail, you’ll need to generate an app password rather than using your regular account password for security.
For Mailgun:
MAILER_DSN=mailgun://key-XXXXXXXXXXXXX@your-domain.com
For SendGrid:
MAILER_DSN=sendgrid://apikey-XXXXXXXXXXXXX@default
Replace these placeholders with your provider’s specific details. Once configured, your application will be ready to send emails.
For common SMTP:
MAILER_DSN=smtp://username@yourmailbox.com:password@smtp.yourmailboxprovider.com:465/?encryption=ssl
Replace these placeholders with your provider’s specific details. Once configured, your application will be ready to send emails.
Structuring Our Data: The MailMessage DTO
To handle incoming emails efficiently, we’ll use a Data Transfer Object (DTO). We’ll create a simple class called MailMessage.php that will hold all the necessary properties for our agent to work with.
To ensure our LLM gets clean data, we’ll implement a bodyPlain property. This property will automatically strip all HTML markup from the main body property. This is a crucial step to avoid wasting valuable tokens on irrelevant code and to optimize the AI’s processing.
For this example, we’ll assume all incoming emails use UTF-8 encoding. While a robust application could include a handler to detect various encodings, this simplified approach is perfect for our purposes.
\
namespace App\DTO;
use Symfony\Component\Mime\HtmlToTextConverter\DefaultHtmlToTextConverter;
class MailMessage implements DataCollectionItemInterface
{
private ?string $subject = null;
private ?string $body = null;
private ?string $bodyPlain = null;
private string $from;
private string $to;
private string $date;
public function __construct(?string $subject, ?string $from, ?string $to, ?string $body, ?string $date)
{
$this->subject = $subject;
$this->from = $from ?? '';
$this->to = $to ?? '';
$this->date = $date ?? '';
if ($this->isValidBody($body)) {
$this->setBody($body);
}
}
public function getSubject(): ?string
{
return $this->subject;
}
public function setSubject(?string $subject): void
{
$this->subject = $subject;
}
private function isValidBody(?string $body): bool
{
return $body !== null && $body !== '';
}
public function getBody(): ?string
{
return $this->body;
}
public function setBody(?string $body): void
{
$this->body = $body;
if ($this->isValidBody($body)) {
$this->convertToText($body);
}
}
public function getFrom(): string
{
return $this->from;
}
public function setFrom(string $from): void
{
$this->from = $from;
}
public function getTo(): string
{
return $this->to;
}
public function setTo(string $to): void
{
$this->to = $to;
}
public function getDate(): string
{
return $this->date;
}
public function setDate(string $date): void
{
$this->date = $date;
}
public function getBodyPlain(): ?string
{
return $this->bodyPlain;
}
public function setBodyPlain(?string $bodyPlain): void
{
$this->bodyPlain = $bodyPlain;
}
public function convertToText(string $charset = 'UTF-8'): void
{
$converter = new DefaultHtmlToTextConverter();
$this->bodyPlain = $converter->convert($this->body, $charset);
}
}
From Arrays to Collections
Since we’ll be dealing with multiple emails, we’ll move away from simple arrays and embrace Collections. Working with collections is much cleaner and more scalable. We’ll set this up by creating a DataCollectionItemInterface.php and a DataCollection.php class.
This architectural choice not only simplifies our current code but also future-proofs our agent. Using a generic data collection allows us to easily add other message sources — like messengers or push notifications etc.. and even create a single, daily summary from a collection of messages, providing a concise digest of your day’s communications.
\
namespace App\DTO;
interface DataCollectionItemInterface {}
namespace App\DTO;
class DataCollection
{
/** @var DataCollectionItemInterface[] */
private array $items = [];
public function __construct(DataCollectionItemInterface ...$items)
{
$this->items = $items;
}
/**
* @param DataCollectionItemInterface $item
*/
public function add(DataCollectionItemInterface $item): void
{
$this->items[] = $item;
}
/**
* @return DataCollectionItemInterface[]
*/
public function getItems(): array
{
return $this->items;
}
/**
* @param string $type
* @return self
*/
public function filterByType(string $type): self
{
$filteredCollection = $this->filterItemsByType($type);
return new self($filteredCollection);
}
/**
* @param string $type
* @return array
*/
private function filterItemsByType(string $type): array
{
return array_filter(
$this->items,
fn($item) => $item instanceof $type
);
}
}
Building the Core Services 🛠️
Now we’ll create the services that power our agent’s workflow.
First, create the ImapMailService.php. This service will encapsulate all the logic for connecting to and retrieving emails from our IMAP mailbox.
To keep things simple, we’ll be working exclusively with emails for now, without any attachments.
This approach allows us to streamline the initial setup and focus on the core functionality of sending basic email content. We will not be handling file attachments for this part of the app.
\
namespace App\Service;
use App\DTO\DataCollection;
use App\DTO\MailMessage;
readonly class ImapMailService
{
private const string CONTENT_TYPE_REGEX = '#Content-Type: text/(plain|htm)#';
public function __construct(private string $host,
private string $username,
private string $password,
private string $mailbox = 'INBOX')
{
}
private function connect(): \IMAP\Connection
{
$mailboxPath = sprintf('{%s}%s', $this->host, $this->mailbox);
$connection = \imap_open($mailboxPath, $this->username, $this->password);
if (!$connection) {
throw new \RuntimeException('Failed to connect to IMAP server: ' . imap_last_error());
}
return $connection;
}
public function fetchEmails(int $limit = 10): DataCollection
{
$collection = new DataCollection();
$connection = $this->connect();
$emails = imap_search($connection, 'ALL', SE_UID);
if (!$emails) {
return $collection;
}
rsort($emails);
$emails = array_slice($emails, 0, $limit);
foreach ($emails as $emailUid) {
$overview = imap_fetch_overview($connection, $emailUid, FT_UID);
$headers = imap_fetchheader($connection, $emailUid, FT_UID);
if ($this->isTextContentType($headers)) {
$body = imap_body($connection, $emailUid, FT_UID);
$collection->add(
new MailMessage(
$overview[0]->subject,
$overview[0]->from,
$overview[0]->to,
$body,
$overview[0]->date,
)
);
}
}
imap_close($connection);
return $collection;
}
private function isTextContentType(string $header): bool
{
return preg_match(self::CONTENT_TYPE_REGEX, $header);
}
}
For maximum flexibility, we’ll store the connection details in the .env file and configure the service in config/services.yaml. This follows a key Symfony best practice, allowing you to easily manage your credentials.
\
App\Service\ImapMailService:
arguments:
$host: '%env(IMAP_HOST)%'
$username: '%env(IMAP_USERNAME)%'
$password: '%env(IMAP_PASSWORD)%'
The AI Brain: AiAgentService and AIProviders🧠
First of all, let’s create a abstract class for AIProviders service class.
\
namespace App\Service\AIProvider;
use Symfony\AI\Agent\Agent;
use Symfony\AI\Platform\Platform;
interface AIProviderServiceInterface
{
public function getAgent(): Agent;
public function getPlatform(): Platform;
}
namespace App\Service\AIProvider;
use Symfony\AI\Agent\Agent;
use Symfony\AI\Platform\Platform;
abstract class AbstractAIProviderService implements AIProviderServiceInterface
{
protected Platform $platform;
protected Agent $agent;
public function getAgent(): Agent
{
return $this->agent;
}
public function getPlatform(): Platform
{
return $this->platform;
}
}
Let’s create a dedicated OpenAI service class.
This service will act as a wrapper or a client for the OpenAI API, centralizing all the logic required to send requests and handle responses. Encapsulating this functionality makes our code cleaner, more modular, and easier to maintain.
\
namespace App\Service\AIProvider;
use Symfony\AI\Agent\Agent;
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
class OpenAIAgentService extends AbstractAIProviderService
{
private Gpt $model;
public function __construct(private string $openaiApiKey)
{
$this->platform = PlatformFactory::create($this->openaiApiKey);
$this->model = new Gpt(Gpt::GPT_4O);
$this->agent = new Agent($this->platform, $this->model);
}
}
Next, let’s create the service that handles all the AI-related logic, AiAgentService.php. This service will contain a single action method.
This method will accept our AIProviderServiceInterface, DataCollection and a specific prompt. It will then communicate with the OpenAI API and return the model’s response as a simple string. This approach keeps the AI logic neatly separated from the rest of our application, making the code cleaner and more maintainable.
\
namespace App\Service;
use App\DTO\DataCollection;
use App\Service\AIProvider\AIProviderServiceInterface;
interface AIAgentServiceInterface
{
public function action(AIProviderServiceInterface $aiProvider, DataCollection $dataCollection, string $prompt): ?string;
}
namespace App\Service;
use App\DTO\DataCollection;
use App\DTO\MailMessage;
use App\Service\AIProvider\AIProviderServiceInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
readonly class AIAgentService implements AIAgentServiceInterface
{
public function action(AIProviderServiceInterface $aiProvider, DataCollection $dataCollection, string $prompt): ?string {
$messageCount = 0;
try {
$messages = new MessageBag(
Message::forSystem($prompt)
);
foreach ($dataCollection->getItems() as $email) {
if ($email instanceof MailMessage) {
if (!is_null($email->getBodyPlain())) {
$messageCount++;
$messages->add(Message::ofUser(
'[Email '.$messageCount.']'.PHP_EOL.
'Subject: '.$email->getSubject().PHP_EOL.
'From: '.$email->getFrom().PHP_EOL.
'Content: '.$email->getBodyPlain().PHP_EOL.
'---'.PHP_EOL));
}
}
}
if ($messageCount === 0) {
throw new \Exception("No messages found", 404);
}
$result = $aiProvider->getAgent()->call($messages);
} catch (\Exception $e) {
echo $e->getMessage();
return null;
}
return $result->getContent();
}
}
Orchestrating the Agent with a Console Command 🚀
The final piece of the puzzle is a console command that brings all our components together. Create a new command called SummarizeCommand. This is what we’ll run from the command line to execute our entire workflow.
The command will perform the following steps:
Fetch new emails using the ImapMailService.
Pass the email content to our AiAgentService with a prompt to generate a summary.
Use the NotifierComponent to send the final summary via email.
\
declare(strict_types=1);
namespace App\Command;
use App\Service\AIAgentService;
use App\Service\AIProvider\OpenAIAgentService;
use App\Service\ImapMailService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;
#[AsCommand(name: 'app:summarize', description: 'Summarize Command')]
class SummarizeCommand extends Command
{
public function __construct(
private readonly NotifierInterface $notifier,
private readonly ImapMailService $imapMailService,
private readonly OpenAIAgentService $openAIAgentService,
private readonly AIAgentService $aiAgentService
){
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$emailCollection = $this->imapMailService->fetchEmails(2);
$result = $this->aiAgentService->action($this->openAIAgentService,$emailCollection, 'I have emails. Please summarize them into a concise overview (100-150 words) focusing on key decisions, action items, and deadlines. Use bullet points to organize the summary by email or theme, whichever is clearer. Here’s the email content:');
if (!is_null($result)) {
$notification = (new Notification('New message'))
->content('Short description: ' .PHP_EOL.PHP_EOL . $result)
->importance(Notification::IMPORTANCE_MEDIUM);
$recipient = new Recipient(
'receipient@mail.com'
);
$this->notifier->send($notification, $recipient);
}
return Command::SUCCESS;
}
}
The final notification you receive will be a clean, concise summary from the LLM, combined with the original email’s subject and any additional text you choose to include.
Now you’re ready to run the command and see your new AI agent in action!
\
./bin/console app:summarize
Conclusion
It’s surprisingly easy to create your own AI agent in short time. This simple example is a great foundation for building more complex and sophisticated workflows.
In future articles, we’ll explore how to take this agent to the next level by:
Improving Performance: Using asynchronous operations to speed up processing.
Advanced Filtering: Filtering messages by specific parameters before they are processed by the AI to save resources and improve efficiency.
Containerization: Placing the entire application into container for easy deployment and scalability.
AI is evolving fast, and the best ideas often come from shared experiences. If you’ve tried building your own agents, faced challenges, or discovered creative use cases, I’d love to hear about them — feel free to share your thoughts in the comments. Your feedback not only helps refine these approaches but also sparks valuable discussions for the whole community.
This series is just getting started, and I’m excited to continue exploring how Symfony can empower developers and companies to build smarter, more efficient applications with AI. Stay tuned — and let’s keep the conversation going.
This content originally appeared on HackerNoon and was authored by MattLeads

MattLeads | Sciencx (2025-08-27T06:43:44+00:00) Forget Plug-and-Play AI—Here’s How to Roll Your Own. Retrieved from https://www.scien.cx/2025/08/27/forget-plug-and-play-ai-heres-how-to-roll-your-own/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.