Skip to content

Product And Category Attribute Content Development

This page covers the developer-facing implementation of Hyvä CMS's Product And Category Attributes feature: module architecture, rendering, storage, and how to add attribute editing to a custom entity.

Module Architecture

The Hyva_CmsMagentoAttributes module keeps the native Magento attribute and renders Hyvä CMS content over it when the attribute is enabled and published.

The flow has three main parts:

  • Storage: one Hyvä CMS liveview row per product or category stores enabled attributes, draft content, published content, and store-view overrides.
  • Providers: product and category providers load, save, version, publish, and store-scope the content for the Liveview Editor.
  • Rendering: plugins and the recommended ViewModel render the CMS HTML for a product or category attribute.

The product and category implementations are deliberately thin. Almost all behavior lives in shared abstract classes, and each entity type contributes only its entity field and EAV type:

  • Repositories extend Hyva\CmsMagentoAttributes\Model\Repository\AbstractLiveviewRepository, which itself extends AbstractAttributeRepository. The abstract layer owns CRUD, exception formatting, and a request-scoped row cache keyed by entity ID so repeated getByEntityId() lookups during a single render do not hit the database again. A concrete repository only sets the ENTITY_FIELD constant (e.g. the product or category ID column).
  • Save controllers extend Hyva\CmsMagentoAttributes\Controller\Adminhtml\AbstractAttributeSave, which handles sanitizing the enabled-attribute IDs, reconciling draft content when the enabled set changes (preserving kept attributes, dropping disabled ones, seeding newly enabled ones), and the JSON response. A concrete controller wires its repositories, factory, entity-ID setter, and EAV type.
  • Schedule providers are a single class, Hyva\CmsMagentoAttributes\Model\Provider\AttributeScheduleProvider, configured per entity type with a virtualType rather than a subclass (see Adding Attribute Editing To A Custom Entity).

Key shared services:

  • Hyva\CmsMagentoAttributes\Model\AttributeCompatibilityResolver
  • Hyva\CmsMagentoAttributes\Model\AttributeHtmlRenderer
  • Hyva\CmsMagentoAttributes\Model\AttributeCodeResolver
  • Hyva\CmsMagentoAttributes\Model\AttributeTypeResolver
  • Hyva\CmsMagentoAttributes\Model\Config (per-entity-type on/off switches, extendable via DI)
  • Hyva\CmsMagentoAttributes\ViewModel\AttributeContent

Disabled content is never exposed

Both the storefront plugins and the GraphQL resolvers gate published content and rendered HTML on the per-entity liveview-enabled flag. When an attribute is disabled, no published content or rendered output is returned through any path, and stored content is still run through ContentRedactor before output.

When implementing product or category templates, always prefer the AttributeContent ViewModel to render Hyvä CMS attribute content.

The ViewModel renders Hyvä CMS content directly. It is simpler, more performant, and works even when the native Magento attribute value is empty.

Implementation Rules

Use these rules when writing code manually or when asking an AI coding tool to implement this feature:

  • Render through Hyva\CmsMagentoAttributes\ViewModel\AttributeContent.
  • Pass the product or category entity and the attribute code to getHtml().
  • Check the returned CMS HTML, not the native attribute value.
  • Do not guard rendering with $entity->getData('attribute_code').
  • Output the returned HTML with @noEscape; the ViewModel returns rendered CMS HTML.
  • Keep wrapper markup in the theme template, not in the attribute value.

Category Example: Render A CMS Attribute On Category Pages

Goal: render a custom category attribute called my_attribute on category view pages.

Create this layout file in your theme or module:

view/frontend/layout/catalog_category_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="Magento\Catalog\Block\Category\View"
                name="vendor.category.cms.my_attribute"
                template="Vendor_Module::category/my-attribute.phtml"
                after="category.products"
            />
        </referenceContainer>
    </body>
</page>

Create the template:

view/frontend/templates/category/my-attribute.phtml
<?php
declare(strict_types=1);

use Hyva\CmsMagentoAttributes\ViewModel\AttributeContent;
use Hyva\Theme\Model\ViewModelRegistry;
use Magento\Catalog\Block\Category\View;
use Magento\Framework\Escaper;

/** @var View $block */
/** @var ViewModelRegistry $viewModels */
/** @var Escaper $escaper */

$category = $block->getCurrentCategory();
if (!$category || !$category->getId()) {
    return;
}

/** @var AttributeContent $attributeContent */
$attributeContent = $viewModels->require(AttributeContent::class);
$html = $attributeContent->getHtml($category, 'my_attribute');

if ($html === null || trim($html) === '') {
    return;
}
?>
<section class="my-attribute-content" aria-label="<?= $escaper->escapeHtmlAttr(__('More information')) ?>">
    <?= /** @noEscape */ $html ?>
</section>

Product Example: Render A CMS Attribute On Product Pages

Goal: render a custom product attribute called my_attribute on product detail pages.

Create this layout file in your theme or module:

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="product.info.main">
            <block
                class="Magento\Framework\View\Element\Template"
                name="vendor.product.cms.my_attribute"
                template="Vendor_Module::product/my-attribute.phtml"
                after="product.info.price"
            />
        </referenceContainer>
    </body>
</page>

Create the template:

view/frontend/templates/product/my-attribute.phtml
<?php
declare(strict_types=1);

use Hyva\CmsMagentoAttributes\ViewModel\AttributeContent;
use Hyva\Theme\Model\ViewModelRegistry;
use Hyva\Theme\ViewModel\ProductPage;
use Magento\Framework\Escaper;

/** @var ViewModelRegistry $viewModels */
/** @var Escaper $escaper */

/** @var ProductPage $productViewModel */
$productViewModel = $viewModels->require(ProductPage::class);
$product = $productViewModel->getProduct();
if (!$product || !$product->getId()) {
    return;
}

/** @var AttributeContent $attributeContent */
$attributeContent = $viewModels->require(AttributeContent::class);
$html = $attributeContent->getHtml($product, 'my_attribute');

if ($html === null || trim($html) === '') {
    return;
}
?>
<section class="my-attribute-content" aria-label="<?= $escaper->escapeHtmlAttr(__('More information')) ?>">
    <?= /** @noEscape */ $html ?>
</section>

If your theme uses a different product page container, keep the PHTML unchanged and move the layout block to the correct container for your theme.

Anti-Pattern: Native Attribute Guard

Do not implement templates like this:

<?php
$value = $product->getData('my_attribute');
if (!$value) {
    return;
}

echo $block->helper('Magento\Catalog\Helper\Output')
    ->productAttribute($product, $value, 'my_attribute');

This checks the native EAV value before Hyvä CMS can render. Hyvä CMS content can exist even when $product->getData('my_attribute') is empty, so this pattern can hide valid CMS content.

Why Use The ViewModel?

The ViewModel calls AttributeHtmlRenderer::render() directly.

The legacy plugin method goes through Magento's output helper first, such as categoryAttribute() or productAttributeHtml(). Magento then performs HTML filtering, event dispatching, and output processing. When Hyvä CMS content exists, the plugin replaces that processed result with CMS content, so the earlier processing work is discarded.

The ViewModel also solves the empty native attribute problem. $entity->getData('my_attribute') can be null even when Hyvä CMS content exists. With legacy rendering, templates that guard with if (!$value) return; can skip rendering before the plugin has a chance to replace the output. The ViewModel checks for Hyvä CMS content directly, so this is not an issue.

Concern ViewModel Legacy Plugin
Performance Direct render Processes native output, then discards it
Empty native value Works Requires a workaround
Store scoping Automatic Automatic
Preview support Automatic Automatic
Tailwind CSS Automatic Automatic

Use the legacy plugin behavior only when you are intentionally rendering through Magento's native attribute output path and understand the limitations.

Attribute Compatibility Rules

Attribute compatibility is resolved by AttributeCompatibilityResolver. It is the single source of truth for:

  • The admin checkbox list.
  • First-time auto-detection.
  • Editor availability.

Rules are configured with dependency injection and keyed by content type. These are the default rules shipped with the module:

etc/di.xml
<type name="Hyva\CmsMagentoAttributes\Model\AttributeCompatibilityResolver">
    <arguments>
        <argument name="rules" xsi:type="array">
            <item name="product_attribute" xsi:type="array">
                <item name="frontend_input" xsi:type="string">textarea</item>
                <item name="flags" xsi:type="array">
                    <item name="pagebuilder" xsi:type="string">is_pagebuilder_enabled</item>
                </item>
            </item>
            <item name="category_attribute" xsi:type="array">
                <item name="frontend_input" xsi:type="string">textarea</item>
                <item name="flags" xsi:type="array">
                    <item name="pagebuilder" xsi:type="string">is_pagebuilder_enabled</item>
                    <item name="wysiwyg" xsi:type="string">is_wysiwyg_enabled</item>
                </item>
            </item>
        </argument>
    </arguments>
</type>

To allow product attributes that use WYSIWYG, add the wysiwyg flag in your own module's etc/di.xml:

etc/di.xml
<type name="Hyva\CmsMagentoAttributes\Model\AttributeCompatibilityResolver">
    <arguments>
        <argument name="rules" xsi:type="array">
            <item name="product_attribute" xsi:type="array">
                <item name="flags" xsi:type="array">
                    <item name="wysiwyg" xsi:type="string">is_wysiwyg_enabled</item>
                </item>
            </item>
        </argument>
    </arguments>
</type>

Because Magento merges DI arrays, you only need to declare the part you change.

Example: Add A Compatible Product Attribute

Goal: create a store-scoped product textarea attribute called product_story that can be managed by Hyvä CMS.

Create a data patch:

Setup/Patch/Data/AddProductStoryAttribute.php
<?php
declare(strict_types=1);

namespace Vendor\Module\Setup\Patch\Data;

use Magento\Catalog\Model\Product;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;

class AddProductStoryAttribute implements DataPatchInterface
{
    public function __construct(
        private readonly ModuleDataSetupInterface $moduleDataSetup,
        private readonly EavSetupFactory $eavSetupFactory
    ) {
    }

    public function apply(): void
    {
        $this->moduleDataSetup->getConnection()->startSetup();

        $eavSetup = $this->eavSetupFactory->create([
            'setup' => $this->moduleDataSetup,
        ]);

        $eavSetup->addAttribute(Product::ENTITY, 'product_story', [
            'type' => 'text',
            'label' => 'Product Story',
            'input' => 'textarea',
            'required' => false,
            'sort_order' => 80,
            'global' => ScopedAttributeInterface::SCOPE_STORE,
            'visible' => true,
            'user_defined' => true,
            'group' => 'Content',
            'is_html_allowed_on_front' => true,
            'is_pagebuilder_enabled' => true,
        ]);

        $this->moduleDataSetup->getConnection()->endSetup();
    }

    public static function getDependencies(): array
    {
        return [];
    }

    public function getAliases(): array
    {
        return [];
    }
}

After the patch is installed, the attribute can appear in the Hyvä CMS panel because it is a textarea attribute with Page Builder enabled.

Example: Add A Compatible Category Attribute

Goal: create a store-scoped category textarea attribute called category_story that can be managed by Hyvä CMS.

Create a data patch:

Setup/Patch/Data/AddCategoryStoryAttribute.php
<?php
declare(strict_types=1);

namespace Vendor\Module\Setup\Patch\Data;

use Magento\Catalog\Model\Category;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;

class AddCategoryStoryAttribute implements DataPatchInterface
{
    public function __construct(
        private readonly ModuleDataSetupInterface $moduleDataSetup,
        private readonly EavSetupFactory $eavSetupFactory
    ) {
    }

    public function apply(): void
    {
        $this->moduleDataSetup->getConnection()->startSetup();

        $eavSetup = $this->eavSetupFactory->create([
            'setup' => $this->moduleDataSetup,
        ]);

        $eavSetup->addAttribute(Category::ENTITY, 'category_story', [
            'type' => 'text',
            'label' => 'Category Story',
            'input' => 'textarea',
            'required' => false,
            'sort_order' => 80,
            'global' => ScopedAttributeInterface::SCOPE_STORE,
            'visible' => true,
            'user_defined' => true,
            'group' => 'General Information',
            'is_html_allowed_on_front' => true,
            'is_wysiwyg_enabled' => true,
        ]);

        $this->moduleDataSetup->getConnection()->endSetup();
    }

    public static function getDependencies(): array
    {
        return [];
    }

    public function getAliases(): array
    {
        return [];
    }
}

After the patch is installed, the attribute can appear in the Hyvä CMS panel because the default category rule accepts textarea attributes with WYSIWYG enabled.

Storage: Attribute Content Tables

Product and category attribute content is stored in dedicated tables:

  • hyva_commerce_product_attribute_liveview
  • hyva_commerce_category_attribute_liveview

Each row belongs to one product or category and stores:

  • The entity ID.
  • Whether Hyvä CMS is enabled for that entity.
  • The enabled attribute IDs.
  • Draft content.
  • Published content.

The stored content contains base content plus store_content overrides keyed by store ID. Attribute content is keyed by attribute ID internally, while import/export normalizes attribute IDs to attribute codes for portability.

Lookups by entity ID are cached for the duration of the request inside AbstractLiveviewRepository, so rendering several attributes for the same product or category reads the row once. The cache is invalidated on save and delete.

REST API Endpoints

All product and category attribute REST endpoints require the Magento_Backend::content ACL.

Entity Base URL
Product attribute content /V1/hyvaProductAttribute
Product attribute versions /V1/hyvaProductAttributeVersionHistory
Category attribute content /V1/hyvaCategoryAttribute
Category attribute versions /V1/hyvaCategoryAttributeVersionHistory

Each base follows Magento service-contract REST conventions with GET /:id, GET /search, POST, PUT /:id, and DELETE /:id where supported.

Adding Attribute Editing To A Custom Entity

For a new entity type, follow the product and category pattern and lean on the shared abstract classes so each new class stays small:

  1. Create the storage model, resource model, API interfaces, and DI preferences. Extend AbstractLiveviewRepository for the repository and set its ENTITY_FIELD constant to your entity's ID column.
  2. Extend AbstractAttributeProvider and register it in Hyva\CmsLiveviewEditor\Model\ProviderPool under your content-type key (e.g. my_attribute).
  3. Add a schedule provider by declaring a virtualType of AttributeScheduleProvider with your entityType argument (no new class needed), and register it in the pool's scheduleProviders argument:

    etc/di.xml
    <virtualType
        name="HyvaCmsMyAttributeScheduleProvider"
        type="Hyva\CmsMagentoAttributes\Model\Provider\AttributeScheduleProvider"
    >
        <arguments>
            <argument name="entityType" xsi:type="string">my_attribute</argument>
        </arguments>
    </virtualType>
    
    <type name="Hyva\CmsLiveviewEditor\Model\ProviderPool">
        <arguments>
            <argument name="scheduleProviders" xsi:type="array">
                <item name="my_attribute" xsi:type="object">HyvaCmsMyAttributeScheduleProvider</item>
            </argument>
        </arguments>
    </type>
    
  4. Add a save controller extending AbstractAttributeSave, implementing the four hooks that wire your repositories, factory, entity-ID setter, and EAV type.

  5. Add a compatibility rule for the new content type (see Attribute Compatibility Rules).
  6. Add admin UI for enabling attributes.
  7. Add frontend rendering through AttributeHtmlRenderer::render() or a ViewModel that delegates to it.

Prefer small entity-specific implementations that reuse the shared abstract classes and services.