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

Adding an "Ask a Question" feature directly to product pages 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 product pages, sends notifications to admins and automated replies to customers, and adds FAQ 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 product page without hurting performance.
  • Controllers and an email/notification workflow (admin alerts + templated auto-reply to customers when an answer is published).
  • JSON-LD FAQ 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.

This is a practical, copy-paste-friendly guide. I’ll use concise code examples and explain where to put things. I’ll follow Magento 2 best practices (db_schema.xml, dependency injection, 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. If you 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. You will 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 product pages 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. Because 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 will be 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 dispatch 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();

If you want asynchronous sending (recommended), push email sending to a queue or a cron job 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: If you 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 FAQ rich snippets

Once a question is answered and made public (status = 1 and visibility flag), you can output a JSON-LD block on the product page that follows Google’s FAQ schema. Keep generation server-side but only for cached public content (answered Q&As). Because content is fairly static once answered, adding this to the product page 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' => 'FAQPage',
        '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. If you 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.
  • Make sure text is cleaned (no script or markup) before output.

Performance considerations

Key points to keep your product pages 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 indexing 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>. Here is the answer:</p>
<p><em>{{var question.question_text}}</em></p>
<p>{{var question.answer_text}}</p>
<p>If you need more help, reply to this email or visit the product page: <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. You can either install a third-party extension (if reputable) or build this custom feature in-house. Here are 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 such as 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 can be monetized with tiers: basic free version (store Q&A storage, admin tools) and premium features (reCAPTCHA integration, multi-language templates, automatic FAQ 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 would be 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 FAQ JSON-LD is valid and limited to allowed number of entries.

Testing and deployment 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 product pages 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.

If you 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.