AI-Powered Semantic Search in Symfony Using PHP and OpenAI Embeddings

PHP CMS Frameworks November 10, 2025

LIKE/MATCH queries have a hard ceiling. I've seen Symfony projects where the client kept complaining that search "doesn't work" and the real issue was never the code, it was that users don't search the way you index. They type "how to reset password" and your database has an article titled "Account Recovery Guide." Zero overlap, zero results.

Switching to OpenAI embeddings fixes this at the architecture level. Instead of matching strings, you convert both the query and your content into vectors and measure how close they are in meaning.

A 1536-dimension float array per article sounds heavy but in practice it's stored as JSON in a text column and the whole thing runs fine on a standard MySQL setup for sites with a few thousand articles.

This tutorial wires it up in Symfony using a console command to generate embeddings and a controller endpoint to run the search. No external vector database needed to get started.

Prerequisites

Before we start, make sure you have:

  • Symfony 6 or 7
  • PHP 8.1+
  • Composer
  • A MySQL or SQLite database
  • An OpenAI API key

Step 1: Create a New Symfony Command

We’ll use a console command to generate embeddings for your existing content (articles, pages, etc.).

Inside your Symfony project, run:

php bin/console make:command app:generate-embeddings

This will create a new file in src/Command/GenerateEmbeddingsCommand.php.

Replace its contents with the following:

src/Command/GenerateEmbeddingsCommand.php

namespace App\Command;

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 Doctrine\ORM\EntityManagerInterface;
use App\Entity\Article;

#[AsCommand(
    name: 'app:generate-embeddings',
    description: 'Generate AI embeddings for all articles'
)]
class GenerateEmbeddingsCommand extends Command
{
    private $em;
    private $apiKey = 'YOUR_OPENAI_API_KEY';
    private $endpoint = 'https://api.openai.com/v1/embeddings';

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $articles = $this->em->getRepository(Article::class)->findAll();
        foreach ($articles as $article) {
            $embedding = $this->getEmbedding($article->getContent());
            if ($embedding) {
                $article->setEmbedding(json_encode($embedding));
                $this->em->persist($article);
                $output->writeln("✅ Generated embedding for article ID {$article->getId()}");
            }
        }

        $this->em->flush();
        return Command::SUCCESS;
    }

    private function getEmbedding(string $text): ?array
    {
        $payload = [
            'model' => 'text-embedding-3-small',
            'input' => $text,
        ];

        $ch = curl_init($this->endpoint);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                "Content-Type: application/json",
                "Authorization: Bearer {$this->apiKey}"
            ],
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($payload)
        ]);

        $response = curl_exec($ch);
        curl_close($ch);

        $data = json_decode($response, true);
        return $data['data'][0]['embedding'] ?? null;
    }
}

This command takes every article from the database, sends its content to OpenAI’s Embedding API, and saves the resulting vector in a database field.

Step 2: Update the Entity

Assume your entity is App\Entity\Article.

We’ll add a new column called embedding to store the vector data.

src/Entity/Article.php

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $embedding = null;

    public function getEmbedding(): ?string
    {
        return $this->embedding;
    }

    public function setEmbedding(?string $embedding): self
    {
        $this->embedding = $embedding;
        return $this;
    }

Then update your database:

    php bin/console make:migration
    php bin/console doctrine:migrations:migrate

Step 3: Create a Search Endpoint

We'll now include a basic controller that takes a search query, turns it into an embedding, and determines which article is the most semantically similar.

src/Controller/SearchController.php

    namespace App\Controller;

    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Routing\Annotation\Route;
    use Doctrine\ORM\EntityManagerInterface;
    use App\Entity\Article;

    class SearchController extends AbstractController
    {
        private $apiKey = 'YOUR_OPENAI_API_KEY';
        private $endpoint = 'https://api.openai.com/v1/embeddings';

        #[Route('/search', name: 'ai_search')]
        public function search(Request $request, EntityManagerInterface $em): Response
        {
            $query = $request->query->get('q');
            if (!$query) {
                return $this->json(['error' => 'Please provide a search query']);
            }

            $queryVector = $this->getEmbedding($query);
            $articles = $em->getRepository(Article::class)->findAll();

            $results = [];
            foreach ($articles as $article) {
                if ($article->getEmbedding()) {
                    $score = $this->cosineSimilarity(
                        $queryVector,
                        json_decode($article->getEmbedding(), true)
                    );
                    $results[] = [
                        'id' => $article->getId(),
                        'title' => $article->getTitle(),
                        'similarity' => $score,
                    ];
                }
            }

            usort($results, fn($a, $b) => $b['similarity'] <=> $a['similarity']);
            return $this->json(array_slice($results, 0, 5)); // top 5 results
        }

        private function getEmbedding(string $text): array
        {
            $payload = [
                'model' => 'text-embedding-3-small',
                'input' => $text,
            ];

            $ch = curl_init($this->endpoint);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => [
                    "Content-Type: application/json",
                    "Authorization: Bearer {$this->apiKey}"
                ],
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => json_encode($payload)
            ]);

            $response = curl_exec($ch);
            curl_close($ch);

            $data = json_decode($response, true);
            return $data['data'][0]['embedding'] ?? [];
        }

        private function cosineSimilarity(array $a, array $b): float
        {
            $dot = 0; $magA = 0; $magB = 0;
            for ($i = 0; $i < count($a); $i++) {
                $dot += $a[$i] * $b[$i];
                $magA += $a[$i] ** 2;
                $magB += $b[$i] ** 2;
            }
            return $dot / (sqrt($magA) * sqrt($magB));
        }
    }

Now, even if the articles don't contain the exact keywords, your /search?q=php framework tutorial endpoint will return those that are most semantically similar to the query.

Step 4: Try It Out

Run the below command.

php bin/console app:generate-embeddings

This generates embeddings for all articles.

Now visit the following URL.

http://your-symfony-app.local/search?q=learn symfony mvc

The top five most pertinent articles will be listed in a JSON response, arranged by meaning rather than keyword.

Real-World Applications

  • A more intelligent search within a CMS or knowledge base
  • AI-supported matching of FAQs
  • Semantic suggestions ("you might also like..."
  • Clustering of topics or duplicates in admin panels

Tips for Security and Performance

  • Reuse and cache embeddings (avoid making repeated API calls for the same content).
  • Keep your API key in.env.local (OPENAI_API_KEY=your_key).
  • For better performance, think about using a vector database such as Pinecone, Weaviate, or Qdrant if you have thousands of records.

Comments · 0

Post a Comment