How to Build a Custom "Ask a Question" Feature for Products in Magento 2

Adding an "Ask a Question" feature directly to página de productos is a small change that can boost conversion, reduce returns, and increase engagement. In this post I’ll walk you through building a lightweight, maintainable custom module for Magento 2 that: stores product questions in the database, exposes an admin interface to manage them, renders a performant frontend form on página de productos, sends notifications to admins and automated replies to customers, and adds Preguntas frecuentes structured data for SEO.

What we’ll build — quick overview

  • A simple database schema for questions and answers (db_schema.xml).
  • Models, resource models and collections to persist questions.
  • An admin grid + edit form so store staff can answer questions.
  • A frontend block and template to add an AJAX "Ask a Question" form on the página de producto without hurting performance.
  • Controllers and an email/notification workflow (admin alerts + templated auto-reply to customers when an answer is published).
  • JSON-LD Preguntas frecuentes markup generation for answered Q&As so Google can show rich snippets.
  • Notes on extension vs custom development and the potential commercial angle for Magefine.

Esto es a practical, copy-paste-friendly guide. I’ll use concise code examples and explain dónde put things. I’ll follow Magento 2 mejores prácticas (db_schema.xml, inyección de dependencias, UI components for admin grids, AJAX endpoints, and email templates).

Module skeleton

Create a module called Magefine_ProductQuestions (namespace Magefine). Folder structure (simplified):

app/code/Magefine/ProductQuestions/
├── registration.php
├── etc
│   ├── module.xml
│   ├── frontend/routes.xml
│   └── adminhtml
│       └── routes.xml
├── Controller
├── Model
├── Setup
├── view
│   ├── frontend
│   │   ├── layout
│   │   └── templates
│   └── adminhtml
│       └── ui_component
└── etc
    └── db_schema.xml

registration.php

<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Magefine_ProductQuestions',
    __DIR__
);

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="Magefine_ProductQuestions" setup_version="1.0.0"/>
</config>

Database architecture

We want a simple structure: product_question table with fields to relate to product, customer (optional), question text, answer text, status, timestamps, visibility. Store answers in same table to keep it simple; you can split into question and answer tables for versioning later.

etc/db_schema.xml (important: use correct types and indexes)

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
  <table name="magefine_product_question" resource="default" engine="innodb" comment="Product questions">
    <column xsi:type="int" name="question_id" nullable="false" unsigned="true" identity="true" comment="Question ID" />
    <column xsi:type="int" name="product_id" nullable="false" unsigned="true" comment="Product ID" />
    <column xsi:type="int" name="customer_id" nullable="true" unsigned="true" comment="Customer ID" />
    <column xsi:type="text" name="question_text" nullable="false" comment="Question" />
    <column xsi:type="text" name="answer_text" nullable="true" comment="Answer" />
    <column xsi:type="smallint" name="status" nullable="false" default="0" comment="0=Pending,1=Answered,2=Hidden" />
    <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" />
    <column xsi:type="timestamp" name="updated_at" nullable="true" on_update="CURRENT_TIMESTAMP" />
    <constraint xsi:type="primary" referenceId="PRIMARY">
      <column name="question_id"/>
    </constraint>
    <index referenceId="MAGEFINE_PRODUCT_ID" indexType="btree">
      <column name="product_id"/>
    </index>
  </table>
</schema>

Why keep it this simple? It’s easier to manage in admin and to render JSON-LD. Si usted need complex moderation history, move to separate tables.

Models and resource models

Create Model/Question.php, Model/ResourceModel/Question.php and Model/ResourceModel/Question/Collection.php. Key methods are simple getters/setters; Magento will handle the rest.

// Model/Question.php
namespace Magefine\ProductQuestions\Model;

use Magento\Framework\Model\AbstractModel;

class Question extends AbstractModel
{
    protected function _construct()
    {
        $this->_init('Magefine\ProductQuestions\Model\ResourceModel\Question');
    }
}

// Model/ResourceModel/Question.php
namespace Magefine\ProductQuestions\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class Question extends AbstractDb
{
    protected function _construct()
    {
        $this->_init('magefine_product_question', 'question_id');
    }
}

// Model/ResourceModel/Question/Collection.php
namespace Magefine\ProductQuestions\Model\ResourceModel\Question;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;

class Collection extends AbstractCollection
{
    protected function _construct()
    {
        $this->_init('Magefine\ProductQuestions\Model\Question', 'Magefine\ProductQuestions\Model\ResourceModel\Question');
    }
}

Admin UI: grid and form

Use UI Components for the admin grid. Usted va a need an admin route, ACL entries and UI component XML for the listing and form. I’ll show the listing component file and a simplified form component. Place listing at view/adminhtml/ui_component/magefine_product_question_listing.xml and a form at magefine_product_question_form.xml. Also add an admin controller for edit/save.

ui_component listing (simplified)

<?xml version="1.0" encoding="UTF-8"?>
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
  <dataSource name="product_questions_data_source">
    <argument name="dataProvider" xsi:type="configurableObject">
      <argument name="class" xsi:type="string">Magefine\ProductQuestions\Model\QuestionDataProvider</argument>
      <argument name="name" xsi:type="string">product_questions_data_source</argument>
      <argument name="primaryFieldName" xsi:type="string">question_id</argument>
      <argument name="requestFieldName" xsi:type="string">question_id</argument>
    </argument>
  </dataSource>
  <columns>
    <column name="question_id"/>
    <column name="product_id"/>
    <column name="question_text"/>
    <column name="status"/>
    <actionsColumn name="actions"/>
  </columns>
</listing>

The DataProvider is a lightweight class extending Magento\Ui\DataProvider\AbstractDataProvider and wires the collection.

For the admin form you’ll provide an editor field for answer_text and a status selector. When admin saves with answer_text and sets status=1 (Answered), we’ll trigger an email workflow (see later).

Frontend integration — display the form without slowing pages

Key goals:

  • Render minimal HTML server-side so página de productos cache nicely (Full Page Cache must not be violated).
  • Use an asynchronous endpoint to submit questions.
  • Only load JS when needed and keep payload small.

Approach: add a layout update catalog_product_view.xml that inserts a small container block. The block HTML is static and cached; the form itself is injected client-side using a tiny RequireJS module that renders the form and handles AJAX submissions. That avoids FPC busting and removes heavy server work from product rendering.

view/frontend/layout/catalog_product_view.xml

<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
  <body>
    <referenceContainer name="product.info.additional">
      <block class="Magento\Framework\View\Element\Template" name="magefine.product.question.container" template="Magefine_ProductQuestions::product/question-container.phtml"/>
    </referenceContainer>
  </body>
</page>

product/question-container.phtml (very light)

<?php /** minimal markup so FPC can cache it */ ?>
<div id="magefine-ask-question" data-product-id="<?= $block->getProduct()->getId(); ?>">
  <button id="magefine-ask-toggle" class="action secondary">Ask a question</button>
  <!-- The real form is injected by JS to avoid FPC issues -->
</div>

Now a small JS module view/frontend/web/js/ask-question.js loaded with RequireJS when the container exists. It will render the form HTML in the client, handle form_key, send AJAX to productquestions/ajax/save, and show success states. Porque it's client-side, the form doesn’t affect page cache and only executes for visitors who interact with the widget.

define(['jquery', 'mage/url', 'mage/storage'], function($, urlBuilder, storage){
  'use strict';
  return function(config){
    var container = $('#magefine-ask-question');
    if (!container.length) return;

    var productId = container.data('product-id');
    var formHtml = '\n      <form id="magefine-ask-form" class="magefine-ask-form">' +
        '' +
        '' +
        '' +
      '';
    container.append(formHtml);

    container.on('submit', '#magefine-ask-form', function(e){
      e.preventDefault();
      var data = $(this).serialize();
      var url = urlBuilder.build('productquestions/ajax/save');
      $.ajax({
        url: url,
        data: data,
        type: 'POST',
        dataType: 'json'
      }).done(function(res){
        if (res.success) {
          container.html('

Thanks! Your question is submitted and será reviewed.

'); } else { container.find('.magefine-error').remove(); container.prepend('

' + (res.message || 'Error') + '

'); } }).fail(function(){ container.prepend('

Network error

'); }); }); } });

Load this module conditionally in the container template via x-magento-init to keep static assets minimal. Example snippet to add after the container div in question-container.phtml:

<script type="text/x-magento-init">
{
  "#magefine-ask-question": {"Magefine_ProductQuestions/js/ask-question":{}}
}
</script>

That way, JS only initializes when DOM contains the container, and page HTML remains cacheable.

AJAX controller and validation

Create Controller/Ajax/Save.php in frontend. Use POST and form_key validation. On save you create a new Question model, set status=0 (pending), created_at etc., and return JSON. Also sanitize input and throttle by IP or customer to avoid spam.

// Controller/Ajax/Save.php (simplified)
namespace Magefine\ProductQuestions\Controller\Ajax;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\Controller\Result\JsonFactory;
use Magefine\ProductQuestions\Model\QuestionFactory;

class Save extends Action
{
    protected $resultJsonFactory;
    protected $questionFactory;

    public function __construct(Context $context, JsonFactory $resultJsonFactory, QuestionFactory $questionFactory)
    {
        parent::__construct($context);
        $this->resultJsonFactory = $resultJsonFactory;
        $this->questionFactory = $questionFactory;
    }

    public function execute()
    {
        $result = $this->resultJsonFactory->create();
        $post = $this->getRequest()->getPostValue();

        if (!$this->getRequest()->isPost()) {
            return $result->setData(['success'=>false,'message'=>'Invalid request']);
        }

        $questionText = trim($post['question_text'] ?? '');
        $productId = (int)($post['product_id'] ?? 0);

        if (!$questionText || !$productId) {
            return $result->setData(['success'=>false,'message'=>'Please provide a question.']);
        }

        // simple anti-spam: limit length
        if (strlen($questionText) > 2000) {
            return $result->setData(['success'=>false,'message'=>'Question too long.']);
        }

        try {
            $question = $this->questionFactory->create();
            $question->setData([
                'product_id' => $productId,
                'question_text' => $questionText,
                'status' => 0
            ]);
            $question->save();
            return $result->setData(['success'=>true]);
        } catch (\Exception $e) {
            return $result->setData(['success'=>false,'message'=>'Server error']);
        }
    }
}

Note: Using $model->save() is fine for small features; for higher scale, use repositories or resource model save to avoid legacy behavior. Also consider reCAPTCHA and rate limiting for production.

Notification workflow — admin alerts and auto-replies

When an admin answers a question in the admin form (sets answer_text and status=1), we want to:

  • Send an email to a configured admin/support email (or multiple emails) to alert them so they can moderate or respond if they didn't answer inline.
  • If a question came from a logged-in customer or includes an email input, send an automated templated email when the answer is published.

Implementation notes:

  • Use events/observers: Observe model save (catalog_product_save_after is not relevant) — we can disparche a custom event in the model resource or use a plugin on ResourceModel::save to detect status change from pending to answered. Another simpler approach is to send notification in the admin controller after saving the form.
  • Create email templates in etc/email and load them by identifier in the code.
  • Use Magento\Framework\Mail\Template\TransportBuilder to build and send emails.

Example: after admin saves, if ($oldStatus == 0 && $newStatus == 1) send notifications.

// inside admin controller save after successfully saving the model
if ($question->getStatus() == 1 && $oldStatus == 0) {
    // notify admins
    $this->notifyAdmin($question);
    // notify customer if email provided
    if ($question->getCustomerEmail()) {
        $this->sendAnswerToCustomer($question);
    }
}

// sendAnswerToCustomer pseudo-code
$transport = $this->transportBuilder
    ->setTemplateIdentifier('magefine_question_answered_template')
    ->setTemplateOptions(['area' => 'frontend','store' => $storeId])
    ->setTemplateVars(['question' => $question, 'product' => $product])
    ->setFromByScope('general')
    ->addTo($question->getCustomerEmail())
    ->getTransport();
$transport->sendMessage();

Si usted want asynchronous sending (recommended), push email sending to a queue or a tarea cron to avoid blocking the admin save operation.

Spam, moderation, and data privacy

Considerations for real sites:

  • Anti-spam: Google reCAPTCHA v2/v3, honeypot fields, rate limiting per IP or per product/cookie, and content filtering.
  • GDPR: Si usted store personal data (emails, names), make sure to allow deletion and to document retention policy — add admin tools to export/delete a user's questions.
  • Moderation: Add statuses (pending, approved, hidden) and optionally a moderation queue for high-traffic stores.

Generating SEO-friendly Preguntas frecuentes rich snippets

Una vez a question is answered and made public (status = 1 and visibility flag), you can output a JSON-LD block on the página de producto that follows Google’s Preguntas frecuentes schema. Keep generation server-side but only for cached public content (answered Q&As). Porque content is fairly static once answered, adding this to the página de producto is safe for caching and beneficial for SEO.

Example JSON-LD generator in the product block template (render only if there are answered Q&As):

<?php
$answered = $questionCollection->addFieldToFilter('product_id', $product->getId())->addFieldToFilter('status', 1);
if ($answered->getSize()) {
    $faq = [
        '@context' => 'https://schema.org',
        '@type' => 'Preguntas frecuentesPage',
        'mainEntity' => []
    ];
    foreach ($answered as $q) {
        $faq['mainEntity'][] = [
            '@type' => 'Question',
            'name' => strip_tags($q->getQuestionText()),
            'acceptedAnswer' => [
                '@type' => 'Answer',
                'text' => strip_tags($q->getAnswerText())
            ]
        ];
    }
    echo '<script type="application/ld+json">' . json_encode($faq) . '</script>';
}
?>

Notes:

  • Only include answered and public Q&As. Si usted add private or user-only answers, exclude them from JSON-LD.
  • Keep JSON-LD size reasonable (Google has limits). If a product has dozens of Q&As, limit to the top N (e.g., 10) most useful answers.
  • Asegúrese text is cleaned (no script or markup) before output.

Performance considerations

Key points to keep your página de productos fast and cache-friendly:

  • Do not include dynamic per-visitor content inside the FPC. Inject interactive parts with client-side code.
  • Limit server-side work: only load answered Q&As for JSON-LD and for public lists. Avoid heavy joins and collection processing in the layout render phase.
  • Cache admin-loaded data where appropriate, but admin pages don’t need FPC; still keep DB queries efficient with proper indexación on product_id and status.
  • Use asynchronous email sending (queue or cron) so admins don't wait for emails to be sent.
  • Compress and minify the small JS module and keep its payload tiny; the code above is deliberately small.

Example admin-to-customer auto-reply template

<!-- etc/email/magefine_question_answered_template.html -->
<h3>Your question has been answered</h3>
<p>Hello {{var customer_name}},</p>
<p>Thanks for your question about <strong>{{var product.name}}</strong>. Aquí está the answer:</p>
<p><em>{{var question.question_text}}</em></p>
<p>{{var question.answer_text}}</p>
<p>Si usted need more help, reply to this email or visit the página de producto: <a href="{{var product.url}}">View product</a>.</p>
<p>Best, <br/>The {{var store.getFrontendName()}} team</p>

Extension vs custom development — pros and cons

Let’s be practical. Puede either install a de terceros extension (if reputable) or build this custom feature in-house. Aquí están the trade-offs I’d discuss with product and ops teams.

Extensions (Pros):

  • Fast to deploy — often configurable and ready to go.
  • May include advanced features como moderation workflows, analytics, and spam protection out-of-the-box.
  • Updates supported by vendor (but check compatibility with your Magento version).

Extensions (Cons):

  • May include features you don’t need — increasing complexity and possible performance overhead.
  • Sometimes poorly maintained; security and compatibility issues are risks.
  • Less flexible for custom integrations with internal systems (CRM, support tools, custom email flows) unless they provide APIs.

Custom development (Pros):

  • Fully tailored to your workflows, data model, and performance constraints.
  • Easier to integrate with internal tooling (CRM, support desks, analytics).
  • Smaller surface area — you only build what you need, keeping it lightweight.

Custom development (Cons):

  • Requires developer time up front and for maintenance.
  • If poorly implemented, it can cause performance issues or security holes.

Business case for Magefine to offer this as an extension or service:

  • Small, well-built "Ask a Question" module puede ser monetized with tiers: basic free version (store Q&A storage, admin tools) and premium features (reCAPTCHA integration, multi-language templates, automatic Preguntas frecuentes publishing, CRM integration, analytics, moderation tools).
  • Offer installation and hosting as a service bundle — Magefine already sells extensions and hosting, so providing a curated, performance-optimized Q&A module plus managed hosting sería attractive to merchants who want to offload complexity.

Commercial features to consider for a premium offering

  • Auto-publish rules: publish answers automatically when pattern-matching approved templates.
  • Integration with Zendesk / Freshdesk / Intercom to create tickets from questions.
  • Analytics dashboard: most asked questions, unanswered questions per product, conversion impact.
  • Bulk moderation tools and CSV export/import.
  • Multi-store and multi-language support including translation workflows for answers.
  • Rich snippet verification tool to ensure Preguntas frecuentes JSON-LD is valid and limited to allowed number of entries.

Testing and despliegue checklist

  • Unit test models and resource models for CRUD operations.
  • Manual QA: submit questions as guest and logged-in users; verify admin grid and answer workflow.
  • Security test: XSS in question/answer fields, validate outputs and sanitize when generating JSON-LD.
  • Performance test: ensure page load time not impacted; check FPC hits/misses before and after deploy.
  • Functional tests on email sending (dev-mode logs or staging SMTP) and rate limiting.

Extra tips from experience

  • Keep the question textarea simple — avoid heavy WYSIWYG editors in public forms; admins can format answers in admin if needed.
  • Keep analytics events for when users click "Ask a question" or submit — useful metric to prioritize product content improvements.
  • Make answers public automatically only after review unless you trust staff workflow.
  • Expose a small badge on página de productos like "Questions answered: 3" to improve credibility and encourage customers to look for answers.
  • Consider caching the JSON-LD separately in a small block cache so it doesn’t run DB queries on every product view when many questions exist.

Wrap-up

That’s the full picture. The implementation is intentionally lightweight but production-ready in structure: db_schema.xml for a stable database, models and resource models to persist data, admin UI via UI Components, client-side injected form to preserve FPC and performance, asynchronous controllers and email workflows, and JSON-LD for SEO.

Si usted want, I can:

  • Generate a downloadable sample module repository with all files and minimal dependencies for testing on a dev environment.
  • Create a premium feature list and pricing suggestions for Magefine to package this as an extension + hosting offering.

Tell me which one you prefer and I’ll produce the repo or the go-to-market plan next.