How to Build a Custom 'Product Story' Module for Enhanced Brand Narratives in Magento 2
Let me walk you through building a lightweight, maintainable "Product Story" module for Magento 2 — the kind of feature that lets marketing teams add brand narratives per product with a WYSIWYG editor, shows beautifully on the product page, and helps with SEO and rich snippets. I’ll keep it practical and conversational, like I’m standing next to you at your desk. Expect step-by-step code examples, architecture notes, and suggestions for media and sharing extensions.
Why a Product Story module?
Short answer: product descriptions are transactional. Product stories are emotional. They let your brand communicate how a product fits into a customer's life. For Magefine customers, adding structured narrative content increases conversion potential and organic visibility when done right.
High-level architecture
We want a design that’s modular and plays nicely with Magento core and common workflows. Here’s the minimal architecture I recommend:
- Module entry: Magefine_ProductStory (module namespace: Magefine/ProductStory)
- Data storage: a custom product attribute (WYSIWYG-enabled textarea) to keep the story per product — EAV-friendly and easy for merchants to edit on product edit page.
- Backend UX: ensure WYSIWYG is enabled; put the attribute into a nice attribute group so admins can find it.
- Frontend: add a block and template to the product detail page (PDP) via layout XML so the story renders responsively and can be toggled / collapsed if long.
- SEO: output JSON-LD structured data containing the narrative as appropriate, and allow admins to add structured subfields if needed.
- Extensions: optional media gallery integration and social share buttons.
Step 1 — Create the module skeleton
Module name: Magefine_ProductStory. Minimal files:
app/code/Magefine/ProductStory/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magefine_ProductStory', __DIR__);
app/code/Magefine/ProductStory/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_ProductStory" setup_version="1.0.0" />
</config>
Keep it simple and register the module. Now create a Data Patch to add the custom product attribute.
Step 2 — Add a custom product attribute (WYSIWYG-enabled)
Use a Data Patch (recommended since Magento 2.3) to create the attribute. This approach keeps the setup repeatable and safe.
app/code/Magefine/ProductStory/Setup/Patch/Data/AddProductStoryAttribute.php
<?php
namespace Magefine\ProductStory\Setup\Patch\Data;
use Magento\Catalog\Model\Product;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\PatchInterface;
class AddProductStoryAttribute implements PatchInterface
{
private $moduleDataSetup;
private $eavSetupFactory;
public function __construct(
ModuleDataSetupInterface $moduleDataSetup,
EavSetupFactory $eavSetupFactory
) {
$this->moduleDataSetup = $moduleDataSetup;
$this->eavSetupFactory = $eavSetupFactory;
}
public function apply()
{
$this->moduleDataSetup->getConnection()->startSetup();
/** @var \Magento\Eav\Setup\EavSetup $eavSetup */
$eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
$eavSetup->addAttribute(
Product::ENTITY,
'product_story',
[
'type' => 'text',
'backend' => '',
'frontend' => '',
'label' => 'Product Story',
'input' => 'textarea',
'class' => '',
'source' => '',
'global' => \Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface::SCOPE_STORE,
'visible' => true,
'required' => false,
'user_defined' => true,
'default' => '',
'wysiwyg_enabled' => true,
'is_html_allowed_on_front' => true,
'visible_on_front' => true,
'used_in_product_listing' => true,
'group' => 'Product Details',
]
);
$this->moduleDataSetup->getConnection()->endSetup();
}
public static function getDependencies()
{
return [];
}
public function getAliases()
{
return [];
}
}
Notes about the attribute fields:
wysiwyg_enabled= true — enables the editor in the admin product form.is_html_allowed_on_front= true — renders HTML on the frontend (careful with XSS; trust content from admins only).visible_on_frontandused_in_product_listing— helpful if you want to show short story excerpts in lists or search results.
Step 3 — Ensure the attribute appears in product edit (Backoffice UX)
By default the attribute will appear in the attribute group you set. For a smoother admin experience, consider these additional touches:
- Put the attribute in a clear group (like "Brand Story" or "Product Details").
- Use descriptive label and help text — you can add a
noteto the attribute or add an admin UI extension if you want. - If marketing needs structure, add a second attribute for a short story excerpt (plain text) used in meta descriptions and teasers.
If you want to programmatically assign the attribute to all attribute sets, add logic in your patch to loop attribute sets and add the attribute to each. Example snippet inside the patch (after addAttribute):
// assign to all attribute sets
$attributeId = $eavSetup->getAttributeId(Product::ENTITY, 'product_story');
$attributeSetIds = $this->moduleDataSetup->getConnection()->fetchAll('SELECT attribute_set_id FROM '.$this->moduleDataSetup->getTable('eav_attribute_set'));
foreach ($attributeSetIds as $set) {
$eavSetup->addAttributeToSet(Product::ENTITY, $set['attribute_set_id'], 'Product Details', $attributeId);
}
Step 4 — Frontend integration: show the Product Story on PDP
We’ll add a block via layout XML so the story appears on the PDP. Keep rendering logic in a small block class and the presentation in a PHTML template.
Layout XML
app/code/Magefine/ProductStory/view/frontend/layout/catalog_product_view.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>
<referenceContainer name="content">
<block class="Magefine\ProductStory\Block\ProductStory" name="magefine.product.story" template="Magefine_ProductStory::product/story.phtml" after="product.info.main" />
</referenceContainer>
</body>
</page>
Block class
app/code/Magefine/ProductStory/Block/ProductStory.php
<?php
namespace Magefine\ProductStory\Block;
use Magento\Catalog\Block\Product\Context;
use Magento\Framework\Escaper;
use Magento\Catalog\Model\Product;
class ProductStory extends \Magento\Framework\View\Element\Template
{
protected $registry;
public function __construct(Context $context, array $data = [])
{
parent::__construct($context, $data);
$this->registry = $context->getRegistry();
}
/**
* Return current product
* @return Product|null
*/
public function getProduct()
{
return $this->registry->registry('current_product');
}
public function getStoryHtml()
{
$product = $this->getProduct();
if (!$product) {
return '';
}
$story = $product->getData('product_story');
return $story;
}
}
Template (phtml)
app/code/Magefine/ProductStory/view/frontend/templates/product/story.phtml
<?php /** @var $block \Magefine\ProductStory\Block\ProductStory */ ?>
$story = $block->getStoryHtml();
if (!trim($story)) {
return; // nothing to show
}
?>
<section class="magefine-product-story" aria-label="Product story">
<div class="magefine-product-story__container">
<h2 class="magefine-product-story__title">The story</h2>
<div class="magefine-product-story__content">
<?php echo $story; ?>
</div>
</div>
</section>
Responsive CSS
app/code/Magefine/ProductStory/view/frontend/web/css/source/_module.less
.magefine-product-story {
padding: 1.5rem 0;
.magefine-product-story__container {
max-width: 900px;
margin: 0 auto;
padding: 0 1rem;
}
.magefine-product-story__title { font-size: 1.25rem; margin-bottom: .75rem; }
.magefine-product-story__content { font-size: 0.98rem; line-height: 1.6; }
}
Include the CSS in module's requirejs-less configuration or via layout head if you prefer. The small styles keep the story block readable on mobile and desktop.
Step 5 — Optimize content rendering and performance
Rendering raw HTML is fine for trusted admin content, but be cautious about performance and security:
- Cache the block's HTML using block cache if possible. Add cache keys that include product ID and store ID.
- Make sure WYSIWYG output doesn’t include inline scripts. If your editors add embeds, consider whitelisting or storing metadata instead of raw script tags.
// Example: add cache lifetime in block class
protected function _construct()
{
parent::_construct();
$this->addData([
'cache_lifetime' => 86400,
'cache_tags' => [\Magento\Catalog\Model\Product::CACHE_TAG],
]);
}
public function getCacheKeyInfo()
{
$product = $this->getProduct();
return ['MAGEFINE_PRODUCT_STORY', $product ? $product->getId() : 'no-product', $this->_storeManager->getStore()->getId()];
}
Step 6 — Structured data and SEO optimizations
One of the big wins: use the story content to enrich structured data so search engines can better understand brand narratives. You can add JSON-LD in the head of the PDP or inline near the story. I prefer JSON-LD in the head, but if the story is dynamic it’s okay to embed it near the content.
Example JSON-LD snippet (Product with a creativeWork part):
<script type="application/ld+json">
{
"@context": "http://schema.org/",
"@type": "Product",
"name": "= $block->escapeHtml($block->getProduct()->getName()) ?>",
"sku": "= $block->escapeHtml($block->getProduct()->getSku()) ?>",
"description": "= $block->escapeHtml($block->getProduct()->getMetaDescription() ?: strip_tags($block->getProduct()->getShortDescription())) ?>",
"brand": {
"@type": "Brand",
"name": "= $block->escapeHtml($block->getProduct()->getAttributeText('manufacturer') ?: 'Your Brand') ?>"
},
"hasPart": {
"@type": "CreativeWork",
"headline": "Product Story",
"text": "= $block->escapeHtml(strip_tags($block->getStoryHtml())) ?>"
}
}
</script>
Why this helps:
- Using
hasPart/CreativeWorktells search engines there is narrative content associated with this product. - Make sure the story text in JSON-LD is succinct — large JSON-LD payloads are allowed, but keep the main description short for snippet rendering.
Step 7 — SEO-friendly fields and suggestions for editors
Merchants should follow simple rules when writing product stories for SEO:
- Start with a short 1-2 sentence lead that could be used as a meta description or snippet.
- Use a clear, keyword-rich headline in the WYSIWYG content (h2/h3), not only visual emphasis.
- Structure content with small paragraphs and bullet lists; long blocks of text reduce readability on mobile.
- Add internal links to relevant collection pages (but be cautious about excessive cross-linking in product descriptions).
Extra technical hints:
- Create a separate attribute for a short story excerpt (e.g.
product_story_short) and use that for meta description and open graph description. - Set
used_in_product_listingtrue if you plan to show excerpts in category lists (improves visual continuity). - Keep content indexable: ensure the story block isn't hidden with CSS that prevents bots from reading it (display:none for toggles is OK if you use progressive enhancement with server-side visible text for bots).
Step 8 — Optional: admin-side enhancements
If your merchants need more structure, consider these admin additions (in order of increasing effort):
- Create a second attribute for a story subtitle or excerpt.
- Add a UI component that shows a preview of how the story will look on mobile/desktop (requires custom admin UI but is very friendly for editors).
- Integrate a media manager field (or add a set of product-linked media entries) so editors can attach gallery images or video IDs to each product story.
Simple example: add a short excerpt attribute (for meta description) in the same data patch with input type text and length limit, then surface that in the product meta fields or SEO tab.
Step 9 — Extending with media: galleries and videos
Naturally, stories are better with multimedia. There are a few ways to approach this:
- Store media references on the product as attributes (e.g.
product_story_video_url,product_story_gallerystoring JSON of image paths). This is easy but less flexible. - Create a separate DB table and an admin UI grid/form that links media items to products (more scalable and ideal for complex stories).
- Integrate with Magento's built-in media gallery (this is possible but requires more plumbing since the gallery is tied to product images).
Example frontend rendering for a simple external video URL (YouTube/Vimeo embed):
// inside story.phtml above the story content
$videoUrl = $block->getProduct()->getData('product_story_video_url');
if ($videoUrl) {
// naive: convert YouTube url to embed
preg_match('/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]+)/', $videoUrl, $match);
if (!empty($match[1])) {
$youtubeId = $match[1];
echo '<div class="magefine-story-video"><iframe src="https://www.youtube.com/embed/'. $this->escapeHtml($youtubeId) . '" frameborder="0" allowfullscreen></iframe></div>';
}
}
For galleries, lazy-load images and consider using a simple lightbox JS library. If you plan to include a gallery per product at scale, keep images in a dedicated table and use pagination or lazy-loading to avoid large payloads on the PDP.
Step 10 — Social sharing and story promotion
Make it easy to share stories. Basic sharing is straightforward: use sharer URLs that include the product URL and optionally story excerpt and OG tags.
// Example social buttons (inside story.phtml)
$productUrl = $block->getProduct()->getProductUrl();
$excerpt = urlencode(substr(strip_tags($block->getStoryHtml()), 0, 200));
echo '<div class="magefine-story-share">';
echo '<a href="https://twitter.com/intent/tweet?text=' . $excerpt . '&url=' . urlencode($productUrl) . '" target="_blank" rel="noopener">Share on Twitter</a>';
echo '<a href="https://www.facebook.com/sharer/sharer.php?u=' . urlencode($productUrl) . '" target="_blank" rel="noopener">Share on Facebook</a>';
echo '<a href="https://www.linkedin.com/shareArticle?mini=true&url=' . urlencode($productUrl) . '&title=' . urlencode($block->getProduct()->getName()) . '" target="_blank" rel="noopener">Share on LinkedIn</a>';
echo '</div>';
Also ensure Open Graph (og:title, og:description, og:image) and Twitter card tags are filled by the product meta data — use the short excerpt attribute as the OG description so shares look good.
Advanced: richer structured data (videoObject, image gallery)
If your stories include video or a dedicated gallery, add corresponding schema markup:
{
"@context": "http://schema.org",
"@type": "Product",
"name": "...",
"image": ["https://your.site/media/..."] ,
"hasPart": {
"@type": "CreativeWork",
"headline": "Product Story",
"text": "..."
},
"isRelatedTo": [
{
"@type": "VideoObject",
"name": "...",
"thumbnailUrl": "https://...",
"uploadDate": "2021-01-01",
"contentUrl": "https://www.youtube.com/watch?v=...",
"embedUrl": "https://www.youtube.com/embed/..."
}
]
}
Google’s structured data testing tools will help you verify the schema. Keep values accurate and avoid stuffing keywords in schema fields.
Testing, QA and deployment tips
- Enable the module on a staging environment first.
- Test product edit UX: editors should see the WYSIWYG and be able to format text, add images, links, etc.
- Verify the story renders across common devices. Use Lighthouse to check mobile performance and accessibility.
- Validate your JSON-LD and Open Graph tags using Rich Results Test and Facebook/Twitter debuggers.
- Watch for 3rd party script tags in WYSIWYG content; sanitize or restrict if necessary.
Performance considerations
Stories typically add HTML and images to the PDP. Keep performance in mind:
- Defer loading heavy media (videos/galleries) until user interaction.
- Use lazy-loading for images and small, responsive image sizes.
- Cache story HTML where possible and purge cache when a product’s content changes.
- If you expect many WYSIWYG images, host them on a CDN and serve webp/optimized formats.
Possible future extensions
Here are a few ways to extend the feature set as you get feedback from marketing and product teams:
- Analytics hooks: track interactions with the story block (scroll depth, play video) and send events to GTM/segment.
- Multi-block stories: support chapters or timed stories with an ordering table in the DB and an admin UI to reorder chapters.
- Localization: make sure the attribute is store-view scoped so each locale has a localized story.
- Shareable short links: create short, shareable story URLs that anchor to the story block; useful for campaigns and social posts.
- Editorial workflow: add approvals for story changes (staging -> live) using a simple approval flag or integrate with an external CMS if needed.
Common pitfalls and how to avoid them
- Overly long WYSIWYG content — it’s tempting to paste full marketing PDFs. Keep stories concise and mobile-friendly.
- Security: do not allow untrusted users to edit WYSIWYG content. If you allow marketers external access, use roles and permissions.
- SEO duplication: avoid copying the same story across many similar products. Prefer product-specific narratives or short shared components.
- Scalability: large images and embedded third-party scripts can cause page speed regressions. Monitor performance after launch.
Complete quick checklist before launch
- Module installed and enabled on staging.
- Attribute present and visible in product edit with WYSIWYG enabled.
- PDP shows the story, responsive CSS applied.
- JSON-LD and OG meta tags reflect story and excerpt fields.
- Lazy-loading and caching for media and story block configured.
- Editors trained on writing short leads, using headings, and attaching media responsibly.
Final thoughts — ship fast, iterate
Start with the simple attribute + PDP render approach. It’s low-risk, quick to deploy, and gives marketing an immediate tool to tell richer brand stories. Over time you can add structured media, editorial workflows and richer schema. For Magefine customers, this kind of module pairs well with hosted Magento setups and SEO-focused extension bundles — keep the content clean, measured, and test performance.
If you want, I can:
- Provide a ready-to-install module skeleton (composer-ready) based on this guide.
- Extend the module with a small admin UI to manage galleries per product.
- Show how to add analytics events for story interactions.
Tell me which one you'd like next and I’ll draft the code snippets for that too.



