Building an AI-Powered Product Description Generator in Magento 2 Using PHP & OpenAI

I was helping a client clean up their Magento store last month and they had over 400 products with either no description or a copy-pasted manufacturer blurb that was identical across 30 items. Writing them manually was not happening.

So I threw together a quick module that puts a button on the product edit page. You click it, it grabs whatever attributes are already filled in and sends them to OpenAI, and a few seconds later the description fields are populated.

Not perfect every time, but good enough as a starting point that you just edit rather than write from scratch.

This tutorial shows you how I built it. The module itself is pretty lightweight, maybe 6 files total, and it works on Magento 2.4 with PHP 8.1.

What we are going to build

  • A button in Magento 2 for the admin that says "Generate AI Description"
  • An AJAX controller that sends product attributes to OpenAI
  • A description, short description, and meta content made by AI
  • Automatic insertion into Magento product fields
  • Optional: button to regenerate to get better results

Requirements

  • Magento 2.4+
  • PHP 8.1+
  • Composer
  • An OpenAI API key
  • Basic module development skills

Step 1: Create a Magento Module Skeleton

Create your module folders:

    app/code/AlbertAI/ProductDescription/

Inside it, create registration.php

    use Magento\Framework\Component\ComponentRegistrar;

    ComponentRegistrar::register(
        ComponentRegistrar::MODULE,
        'AlbertAI_ProductDescription',
        __DIR__
    );

Then create etc/module.xml

    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
        <module name="AlbertAI_ProductDescription" setup_version="1.0.0"/>
    </config>

Enable the module:

    php bin/magento setup:upgrade

Step 2: On the Product Edit Page, add a button that says "Generate Description."

Create a file: view/adminhtml/layout/catalog_product_edit.xml

    <?xml version="1.0"?>
    <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">

        <body>
            <referenceBlock name="product_form">
                <block class="AlbertAI\ProductDescription\Block\Adminhtml\GenerateButton"
                    name="ai_description_button"/>
            </referenceBlock>
        </body>
    </page>

Step 3: Create the Admin Button Block

File: Block/Adminhtml/GenerateButton.php

    namespace AlbertAI\ProductDescription\Block\Adminhtml;

    use Magento\Backend\Block\Template;

    class GenerateButton extends Template
    {
        protected $_template = 'AlbertAI_ProductDescription::button.phtml';
    }

Step 4: The Button Markup

File: view/adminhtml/templates/button.phtml

    <button id="ai-generate-btn" class="action-default scalable primary">
        Generate AI Description
    </button>

    <script>
    require(['jquery'], function ($) {
        $('#ai-generate-btn').click(function () {
            const productId = $('#product_id').val();

            $.ajax({
                url: 'getUrl("ai/generator/description") ?>',
                type: 'POST',
                data: { product_id: productId },
                success: function (res) {
                    if (res.success) {
                        $('#description').val(res.description);
                        $('#short_description').val(res.short_description);
                        $('#meta_description').val(res.meta_description);
                        alert("AI description ready!");
                    } else {
                        alert("Error: " + res.error);
                    }
                }
            });
        });
    });
    </script>

Step 5: Create an Admin Route

File: etc/adminhtml/routes.xml

    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">

        <router id="admin">
            <route id="ai" frontName="ai">
                <module name="AlbertAI_ProductDescription"/>
            </route>
        </router>
    </config>

Step 6: Build the AI Controller That Calls OpenAI

File: Controller/Adminhtml/Generator/Description.php

    namespace AlbertAI\ProductDescription\Controller\Adminhtml\Generator;

    use Magento\Backend\App\Action;
    use Magento\Catalog\Api\ProductRepositoryInterface;
    use Magento\Framework\Controller\Result\JsonFactory;

    class Description extends Action
    {
        protected $jsonFactory;
        protected $productRepo;
        private $apiKey = "YOUR_OPENAI_API_KEY";

        public function __construct(
            Action\Context $context,
            ProductRepositoryInterface $productRepo,
            JsonFactory $jsonFactory
        ) {
            parent::__construct($context);
            $this->productRepo = $productRepo;
            $this->jsonFactory = $jsonFactory;
        }

        public function execute()
        {
            $result = $this->jsonFactory->create();

            $id = $this->getRequest()->getParam('product_id');
            if (!$id) {
                return $result->setData(['success' => false, 'error' => 'Product not found']);
            }

            $product = $this->productRepo->getById($id);

            $prompt = sprintf(
                "Write an SEO-friendly product description.\nProduct Name: %s\nBrand: %s\nFeatures: %s\nOutput: Long description, short description, and meta description.",
                $product->getName(),
                $product->getAttributeText('manufacturer'),
                implode(', ', $product->getAttributes())
            );

            $generated = $this->generateText($prompt);

            return $result->setData([
                'success' => true,
                'description' => $generated['long'],
                'short_description' => $generated['short'],
                'meta_description' => $generated['meta']
            ]);
        }

        private function generateText($prompt)
        {
            $body = [
                "model" => "gpt-4.1-mini",
                "messages" => [
                    ["role" => "user", "content" => $prompt]
                ]
            ];

            $ch = curl_init("https://api.openai.com/v1/chat/completions");
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => [
                    "Content-Type: application/json",
                    "Authorization: Bearer " . $this->apiKey
                ],
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => json_encode($body)
            ]);
            $response = json_decode(curl_exec($ch), true);
            curl_close($ch);

            $text = $response['choices'][0]['message']['content'] ?? "No response";

            // Split via sections
            return [
                'long' => $this->extract($text, 'Long'),
                'short' => $this->extract($text, 'Short'),
                'meta' => $this->extract($text, 'Meta')
            ];
        }

        private function extract($text, $type)
        {
            preg_match("/$type Description:\s*(.+)/i", $text, $m);
            return $m[1] ?? $text;
        }
    }

Step 7: Test It

  • Go to Magento Admin → Catalog → Products
  • Edit any product
  • Click “Generate AI Description”
  • Descriptions fields will auto-fill in seconds

Bonus Tips

You can extend the module to generate:

  • Product titles
  • Bullet points
  • FAQ sections
  • Meta keywords
  • Category descriptions

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

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.

AI Text Summarization for Drupal 11 Using PHP and OpenAI API

Drupal's body field has a built-in summary subfield that almost nobody fills in properly. On high-volume editorial sites I've worked on, it's either blank, copy-pasted from the first paragraph, or written by someone who clearly didn't read the article. It shows up in teasers, RSS feeds, and meta descriptions, so bad summaries actually hurt.

The fix is straightforward. Hook into hook_entity_presave, grab the body content, send it to OpenAI, write the result back into body->summary before the node hits the database. Editors never have to touch it, and the summaries are actually coherent.

This is a single-file custom module. No services, no config forms, no dependencies beyond cURL. If you want to wire it up properly with Drupal's config system later you can, but this gets you running in under 20 minutes.

Prerequisites

Before starting, make sure you have:

Step 1: Create a Custom Module

Create a new module called ai_summary.

/modules/custom/ai_summary/

Inside that folder, create two files:

  • ai_summary.info.yml
  • ai_summary.module

ai_summary.info.yml

Add the below code in the info.yml file.

   name: AI Summary
   type: module
   description: Automatically generate summaries for Drupal nodes using OpenAI API.
   core_version_requirement: ^11
   package: Custom
   version: 1.0.0

ai_summary.module

This is where the logic lives.

To run our code just before a node is saved we will use hook_entity_presave of Drupal.

use Drupal\node\Entity\Node;
use Drupal\Core\Entity\EntityInterface;

/**
* Implements hook_entity_presave().
*/
function ai_summary_entity_presave(EntityInterface $entity) {
    if ($entity->getEntityTypeId() !== 'node') {
        return;
    }

    // Only summarize articles (you can change this as needed)
    if ($entity->bundle() !== 'article') {
        return;
    }

    $body = $entity->get('body')->value ?? '';
    if (empty($body)) {
        return;
    }

    // Generate AI summary
    $summary = ai_summary_generate_summary($body);

    if ($summary) {
        // Save it in the summary field
        $entity->get('body')->summary = $summary;
    }
}

/**
* Generate summary using OpenAI API.
*/
function ai_summary_generate_summary($text) {
    $api_key = 'YOUR_OPENAI_API_KEY';
    $endpoint = 'https://api.openai.com/v1/chat/completions';

    $payload = [
        "model" => "gpt-4o-mini",
        "messages" => [
        ["role" => "system", "content" => "Summarize the following text in 2-3 sentences. Keep it concise and human-readable."],
        ["role" => "user", "content" => $text]
        ],
        "temperature" => 0.7
    ];

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

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

    $data = json_decode($response, true);
    return trim($data['choices'][0]['message']['content'] ?? '');
}

This functionality performs three primary functions:

  • Identifies article saving in Drupal.
  • Sends the content to OpenAI to be summarized.
  • The summary is stored in the body summary field of the article.

Step 2: Enable the Module

  1. Place the new module folder directly in /modules/custom/.
  2. In Drupal Admin panel, go to: Extend → Install new module (or Enable module).
  3. Check AI Summary and turn it on.

Step 3: Test the AI Summary

  1. Select Content -> Add content -> Article.
  2. Enter the long paragraph in the body field.
  3. Save the article.
  4. On reloading the page, open it one more time — the summary field will be already filled automatically.

Example:

Input Body:

Artificial Intelligence has been changing how developers build and deploy applications...

Generated Summary:

AI is reshaping software development by automating repetitive tasks and improving decision-making through data-driven insights.

Step 4: Extend It Further

The following are some of the ideas that can be used to improve the module:

  • Add settings: Add a form to enable the user to add the API key and the select the type of model.
  • Queue processing: Queue processing Use the drugndrup queue API to process the existing content in batches.
  • Custom field storage: Store summaries in object now: field_ai_summary.
  • Views integration: Show or hide articles in terms of length of summary or its presence.

Security & Performance Tips

  • Never hardcode your API key but keep it in the configuration or in the.env file of Drupal.
  • Shorten long text in order to send (OpenAI token limit = cost).
  • Gracefully manage API timeouts.
  • Watchdoging errors to log API.

Building a Sentiment Analysis Plugin in Joomla Using PHP and OpenAI API

Joomla's content plugin system is underused. Most developers reach for components when a simple content plugin hooked into onContentBeforeSave would do the job in a fraction of the code.

This tutorial is a good example of that. The idea is simple: every time an article is saved, we send the text to OpenAI and get back one word, positive, negative, or neutral.

That result gets appended to the meta keywords field and flashed as an admin message. Nothing fancy, but on a community site or news portal where editors are processing dozens of submissions a day, having that sentiment label right in the save workflow saves real time.

Two files, no Composer, no service container. Just a manifest XML and a single PHP class extending CMSPlugin.

What You’ll Need

Before we start, make sure you have:

  • Joomla 5.x installed
  • PHP 8.1 or newer
  • cURL enabled on your server
  • An OpenAI API key

Once that’s ready, let’s code.

Step 1: Creation of the Plugin

In your Joomala system, make a new folder within the system under the name of the plugin:

/plugins/content/aisentiment/

Thereupon in that folder generate two files:

  • aisentiment.php
  • aisentiment.xml

aisentiment.xml

This is the manifest file that the Joomla plugin identifies the identity of this particular plugin and the files that should be loaded into it.

<?xml version="1.0" encoding="utf-8"?>

<extension type="plugin" version="5.0" group="content" method="upgrade">

    <name>plg_content_aisentiment</name>

    <author>PHP CMS Framework</author>

    <version>1.0.0</version>

    <description>Analyze sentiment of comments or articles using OpenAI API.</description>

    <files>

        <filename plugin="aisentiment">aisentiment.php</filename>

    </files>

</extension>


Step 2: Add the PHP Logic

Now let’s write the plugin code.

aisentiment.php

<?php
defined('_JEXEC') or die;

use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Factory;

class PlgContentAisentiment extends CMSPlugin
{
    private $apiKey = 'YOUR_OPENAI_API_KEY';
    private $endpoint = 'https://api.openai.com/v1/chat/completions';

    public function onContentBeforeSave($context, $table, $isNew, $data)
    {
        // Only process if content exists
        if (empty($data['introtext']) && empty($data['fulltext'])) {
            return true;
        }

        $text = strip_tags($data['introtext'] ?? $data['fulltext']);
        $sentiment = $this->getSentiment($text);

        if ($sentiment) {
            $data['metakey'] .= ' Sentiment:' . ucfirst($sentiment);
            Factory::getApplication()->enqueueMessage("AI Sentiment: {$sentiment}", 'message');
        }

        return true;
    }

    private function getSentiment($text)
    {
        $payload = [
            "model" => "gpt-4o-mini",
            "messages" => [
                ["role" => "system", "content" => "You are a sentiment analysis model. Return only one word: positive, negative, or neutral."],
                ["role" => "user", "content" => $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),
            CURLOPT_TIMEOUT => 15
        ]);

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

        $data = json_decode($response, true);
        return strtolower(trim($data['choices'][0]['message']['content'] ?? 'neutral'));
    }
}

This extension will be installed on Joomla and will work together with the content workflow (onContentBeforeSave) and send the content of the article to the OpenAI API. This model examines the tone and produces one of three values, including positive, negative, and neutral.

The output is presented in the administration of Joomla as an output when the article is saved.


Step 3:Install and activate the Plugin.

Zip the folder (aisentiment) of the compressor: aisentiment.zip

Within Joomla administration log, go to: System → Extensions → Install

Upload the zip file.

Once installed, visit System -> Plugins where you will find the AI Sentiment one and turn it on.


Step 4: Test It

Open an existing article, or create one.

Enter some text — for example:

This product is out of my expectations and it works excellently!

Click Save.

Joomla will show such a message as:  AI Sentiment: positive

Or you can save the response in a custom field and present it on the front end.


Bonus Tips:

  • Store your API key securely in Joomla’s configuration or an environment variable (not hard-coded).
  • Add caching if you’re analyzing large volumes of content.
  • Trim long text before sending to OpenAI to save API tokens.
  • Handle failed API calls gracefully with proper fallbacks.

Real-World Use Cases:

  • Highlight positive user reviews automatically.
  • Flag negative feedback for moderation.
  • Generate sentiment dashboards for community comments.
In the next part of our AI + PHP CMS series, we’ll move to Drupal 11, where we’ll build an AI Text Summarization module using PHP and OpenAI API.