DEV Community

Cover image for Symfony AI Store: The Missing Link for RAG in PHP
Matt Mochalkin
Matt Mochalkin

Posted on • Edited on

Symfony AI Store: The Missing Link for RAG in PHP

For years, PHP developers watched the AI revolution unfold from a slight distance. We hacked together Python microservices, wrestled with raw API calls to OpenAI, or relied on experimental libraries that broke with every minor release.

With the release of Symfony 7.4 and the maturity of the Symfony AI Initiative, we finally have a first-class citizen for building AI-native applications. While symfony/ai-platform handles the chat models, the real game-changer for business applications is symfony/ai-store.

This component is the backbone of Retrieval-Augmented Generation (RAG) in PHP. It abstracts the complexity of vector databases — whether you’re using Redis, PostgreSQL (pgvector), or Elasticsearch — into a clean, recognizable Symfony interface.

In this article, we’re going deep. We will build a knowledge base search engine using symfony/ai-store and Symfony 7.4, utilizing PHP 8.4’s latest features.

Why symfony/ai-store Matters

Before we write code, we need to understand the architecture. Large Language Models (LLMs) like GPT-4 are brilliant but have two fatal flaws:

  1. Hallucination: They make things up.
  2. Amnesia: They don’t know your private business data.

RAG solves this by “grounding” the AI with your data. You convert your documentation or products into “vectors” (lists of numbers representing meaning) and store them. When a user asks a question, you find the most similar vectors and feed them to the AI.

symfony/ai-store provides the standard interface for that middle step: the Vector Store.

Installation and Setup

We will install the AI Bundle, which includes the Store component and simplifies configuration. We’ll also need a transport. For this tutorial, we’ll use Doctrine with PostgreSQL (using pgvector), as it’s the most common stack for Symfony developers.

composer require symfony/ai-bundle symfony/ai-postgres-store
Enter fullscreen mode Exit fullscreen mode

Ensure you have a running PostgreSQL instance with the vector extension enabled.

Check that the bundle is active and the store commands are available:

php bin/console list ai
Enter fullscreen mode Exit fullscreen mode

You should see commands like ai:store:setup.

Configuration

In Symfony 7.4, we prefer explicit configuration. Open your config/packages/ai.yaml.

We will define a default store that uses the Doctrine transport.

# config/packages/ai.yaml
ai:
    platform:
        gemini:
            api_key: '%env(GEMINI_API_KEY)%'
        openai:
            api_key: '%env(OPENAI_API_KEY)%'

    agent:
        default:
            platform: 'ai.platform.gemini'
            model: 'gemini-2.5-pro'
        openai:
            platform: 'ai.platform.openai'
            model: 'text-embedding-3-small'

    vectorizer:
        default:
            platform: 'ai.platform.openai'
            model: 'text-embedding-3-small'

    store:
        postgres:
            default:
                table_name: 'tb_symfony_vector'

        memory:
            default:

    message_store:
        cache:
            default:
                service: 'cache.app'
Enter fullscreen mode Exit fullscreen mode

The Database Migration

The symfony/ai-postgres-store" package allows us to generate the schema automatically.

php bin/console ai:store:setup ai.store.postgres.default
Enter fullscreen mode Exit fullscreen mode

This command will interact with your database to create the necessary table (e.g., vector_documents) with the correct vector column type.

In production, you should use Doctrine Migrations. The ai:store:setup command is excellent for rapid prototyping, but for CI/CD pipelines, generate a migration that executes the SQL required to enable the extension and create the table.

The Core Concept: Documents

The Store component doesn’t save your complex Doctrine Entities directly. It saves Documents. A Document is a simple DTO (Data Transfer Object) containing:

  1. ID: Unique identifier.
  2. Content: The actual text the AI will read.
  3. Metadata: Arbitrary array for filtering (e.g., author_id, created_at).
  4. Vectors: The calculated embeddings (handled automatically).

Building the Ingestion Service

Let’s create a service that takes a blog post (or any entity), converts it into a Document and saves it to the store.

We will use PHP 8.4 attributes for dependency injection.

namespace App\Service;

use App\Entity\BlogPost;
use Symfony\AI\Platform\Vector\VectorInterface;
use Symfony\AI\Store\Document\Metadata;
use Symfony\AI\Store\Document\VectorDocument;
use Symfony\AI\Store\Document\VectorizerInterface;
use Symfony\AI\Store\StoreInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;


readonly class KnowledgeBaseIndexer
{
    public function __construct(
        // Inject the default store configured in YAML
        #[Autowire(service: 'ai.store.postgres.default')]
        private StoreInterface $store,
        #[Autowire(service: 'ai.vectorizer.default')]
        private VectorizerInterface $vectorizer
    ) {}

    public function indexBlogPost(BlogPost $post): void
    {
        // 1. Prepare the content for the LLM.
        // Concatenate title and body for better context.
        $content = sprintf(
            "Title: %s\n\n%s",
            $post->getTitle(),
            $post->getContent()
        );

        $vector = $this->vectorizer->vectorize($content);

        if ($vector instanceof VectorInterface) {

            // 2. Create the AI Document

            $document = new VectorDocument(
                id: $post->getId(),
                vector: $vector,
                metadata: new Metadata ([
                    'type' => 'blog_post',
                    'content' => $content,
                    'author_id' => $post->getAuthor()->getId(),
                    'published_at' => $post->getCreatedAt()->format('Y-m-d'),
                ])
            );

            $this->store->add($document);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode
  1. Detects the configured embedding model (text-embedding-3-small).
  2. Sends the $content to OpenAI via the API.
  3. Receives the vector float array.
  4. Inserts the text, metadata and vector into the PostgreSQL database.

Building the Retrieval Service

Now for the magic. We want to ask a question and find relevant blog posts.

namespace App\Service;

use Symfony\AI\Store\Document\VectorizerInterface;
use Symfony\AI\Store\StoreInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

readonly class KnowledgeBaseSearch
{
    public function __construct(
        #[Autowire(service: 'ai.store.postgres.default')]
        private StoreInterface $store,
        #[Autowire(service: 'ai.vectorizer.default')]
        private VectorizerInterface $vectorizer,
    ) {}

    /**
     * @return array<int, string> List of relevant content chunks
     */
    public function search(string $userQuery, int $limit = 3): array
    {
        $vector = $this->vectorizer->vectorize($userQuery);

        // The query() method automatically embeds the user's question
        // using the same model as the store, ensuring vector compatibility.
        $results = $this->store->query(
            $vector,
            [
                'limit' => $limit,
                'where' => "metadata->>'type' = :type",
                'params' => ['type' => 'blog_post'],
            ]
        );

        $answers = [];

        foreach ($results as $result) {
            // $result is a ScoredDocument object

            // Basic threshold to filter out noise
            if ($result->score < 0.7) {
                continue;
            }

            $answers[] = $result->metadata['content'];
        }

        return $answers;
    }
}
Enter fullscreen mode Exit fullscreen mode

Putting it Together: The RAG Controller

Finally, let’s wire this into a controller that uses the retrieved data to generate an answer.

namespace App\Controller;

use App\Service\KnowledgeBaseSearch;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\AI\Chat\ChatInterface;

#[Route('/api/ai')]
class AssistantController extends AbstractController
{
    public function __construct(
        private KnowledgeBaseSearch $searchService,
        private ChatInterface $chat, // Provided by symfony/ai-platform
    ) {}

    #[Route('/ask', methods: ['POST'])]
    public function ask(Request $request): JsonResponse
    {
        $question = $request->getPayload()->get('question');

        // 1. Retrieve relevant context from our Vector Store
        $contextDocuments = $this->searchService->search($question);

        $contextString = implode("\n---\n", $contextDocuments);

        // 2. Construct the prompt with context (RAG)
        $systemPrompt = <<<PROMPT
You are a helpful assistant for our company blog.
Answer the user's question based ONLY on the context provided below.
If the answer is not in the context, say "I don't know."

Context:
$contextString
PROMPT;

        // 3. Call the LLM


        $this->chat->initiate(
            new MessageBag(Message::forSystem($systemPrompt))
        );

        $response = $this->chat->submit(
            Message::ofUser($question)
        );

        return $this->json([
            'answer' => $response->getContent(),
            'sources' => count($contextDocuments) // Transparency is key!
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Configuration: Multiple Stores

In a real-world enterprise app, you might have different stores for different data types (e.g., products_store vs documentation_store) or different backends (Redis for hot session memory, Postgres for long-term knowledge).

Symfony 7.4 makes this trivial with bind or target attributes.

config/packages/ai.yaml:

ai:
    store:
        postgres:
            default:
                table_name: 'tb_symfony_vector'

        memory:
            default:
Enter fullscreen mode Exit fullscreen mode

Service Injection:

Performance Pattern: Decoupling Ingestion with Messenger

In the previous section, we indexed the blog post immediately. In a production environment, this is a performance bottleneck.

Calling OpenAI (or any LLM provider) to generate embeddings involves an HTTP request that can take anywhere from 200ms to several seconds. If you do this synchronously while an editor hits “Save” in your CMS, their browser will hang. If the API is down, your application throws an error.

The solution is to decouple the ingestion using Symfony Messenger. We will dispatch a lightweight message containing the ID of the content and let a background worker handle the heavy lifting of embedding and vector storage.

Create the Message

We follow the “Thin Message” pattern. Never pass the full Entity or the large text content in the message. Pass only the identifier.

namespace App\Messenger\Message;

use Symfony\Component\Uid\Uuid;

readonly class IndexBlogPostMessage
{
    public function __construct(
        public Uuid $blogPostId,
    ) {}
}

Enter fullscreen mode Exit fullscreen mode

Create the Handler

The handler is where we glue the pieces together. It fetches the fresh entity from the database and passes it to our existing KnowledgeBaseIndexer.

namespace App\Messenger\Handler;

use App\Messenger\Message\IndexBlogPostMessage;
use App\Repository\BlogPostRepository;
use App\Service\KnowledgeBaseIndexer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
readonly class IndexBlogPostHandler
{
    public function __construct(
        private BlogPostRepository $repository,
        private KnowledgeBaseIndexer $indexer,
    ) {}

    public function __invoke(IndexBlogPostMessage $message): void
    {
        // 1. Re-fetch the entity
        $post = $this->repository->find($message->blogPostId);

        // 2. Handle edge case: Entity might have been deleted
        // before the worker picked up the job.
        if (!$post) {
            return;
        }

        // 3. Delegate to the heavy-lifting service defined in Section 4
        $this->indexer->indexBlogPost($post);
    }
}
Enter fullscreen mode Exit fullscreen mode

Dispatching the Event

Now, update your Controller (or Event Listener) to dispatch the message instead of calling the indexer directly.

namespace App\Controller\Admin;

use App\Entity\BlogPost;
use App\Messenger\Message\IndexBlogPostMessage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;

class BlogAdminController extends AbstractController
{
    public function __construct(
        private MessageBusInterface $bus,
    ) {}

    #[Route('/admin/post/{id}/publish', methods: ['POST'])]
    public function publish(BlogPost $post): Response
    {
        // ... (Your existing logic to save/publish the post) ...

        // Instead of indexing immediately:
        // $indexer->indexBlogPost($post); // REMOVE THIS

        // Dispatch to the background queue:
        $this->bus->dispatch(new IndexBlogPostMessage($post->getId()));

        return $this->json(['status' => 'published', 'job_id' => 'queued']);
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

The symfony/ai-store component is a watershed moment for PHP. We no longer need to rely on Python sidecars or brittle HTTP wrappers to implement vector search. It brings the power of RAG directly into the Dependency Injection container we know and love.

Key Takeaways:

  1. Abstraction: Swap vector databases (Redis -> Postgres) without changing your PHP code.
  2. Integration: Works seamlessly with symfony/ai-platform for embedding generation.
  3. Simplicity: Treating vectors as “Documents” fits the Symfony mental model perfectly.

The ecosystem is moving fast. Today it’s text; tomorrow it will be multi-modal (images/audio). By adopting symfony/ai-store now, you are future-proofing your application for the AI era.

Integrating AI into Symfony 7.4 has never been this streamlined. We moved from “experimental” to “production-ready” in record time. If you aren’t using Vector Stores yet, you are building an AI with one hand tied behind its back.

Let’s connect! I write about high-performance Symfony architecture and AI integration every week.

Source Code: You can find the full implementation and follow the project's progress on GitHub: [https://github.com/mattleads/AIStoreSample]

👉 Follow me on LinkedIn [https://www.linkedin.com/in/matthew-mochalkin/] for weekly tips and let me know: What are you building with Symfony AI?

Top comments (3)

Collapse
 
oskarstark profile image
Oskar Stark

Thanks for writing about Symfony AI Store — it’s great to see our RAG and vector search in PHP getting attention. Symfony AI is a promising set of components for Platform, Agent, Chat, and Store workflows. 

However, there are a few factual issues in the post that I’d encourage you to review against the official Symfony documentation:

• ChatInterface does not have a complete() method — the documented way to make model calls is via the AgentInterface->call() or using Platform->invoke() for direct model invocation. ChatInterface uses ->submit(), but has nothing todo with Store.
• The Store component (symfony/ai-store) is a separate package that must be explicitly installed (composer require symfony/ai-store) — it is not automatically included just by requiring symfony/ai-bundle. 
• Likewise, the Doctrine Store bridge is not required by default — Symfony AI supports many optional store backends (MongoDB, ChromaDB, Redis, Pinecone, etc.). 
• Some of the YAML examples in your post differ from how config is shown in the official docs. 

Please double-check with the official Symfony docs and examples and make sure the code is working. Thanks

Collapse
 
mattleads profile image
Matt Mochalkin

Thank you so much for the detailed feedback! I really appreciate you taking the time to point those out.

It’s a fair point—sharing code that doesn't actually compile or align with the documentation is a headache for everyone. I've updated the article to fix those technical inaccuracies regarding the interfaces and the decoupled nature of the ai-store component.

To make things right and provide a reliable reference, I’ve put together a working implementation on GitHub that follows the official Symfony AI structure.

Collapse
 
oskarstark profile image
Oskar Stark

Thanks, much appreciated 🙋‍♂️