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 extendsAbstractAttributeRepository. The abstract layer owns CRUD, exception formatting, and a request-scoped row cache keyed by entity ID so repeatedgetByEntityId()lookups during a single render do not hit the database again. A concrete repository only sets theENTITY_FIELDconstant (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 avirtualTyperather than a subclass (see Adding Attribute Editing To A Custom Entity).
Key shared services:
Hyva\CmsMagentoAttributes\Model\AttributeCompatibilityResolverHyva\CmsMagentoAttributes\Model\AttributeHtmlRendererHyva\CmsMagentoAttributes\Model\AttributeCodeResolverHyva\CmsMagentoAttributes\Model\AttributeTypeResolverHyva\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.
Rendering With The AttributeContent ViewModel (Recommended)
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:
<?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:
<?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:
<?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:
<?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:
<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:
<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:
<?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:
<?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_liveviewhyva_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:
- Create the storage model, resource model, API interfaces, and DI preferences. Extend
AbstractLiveviewRepositoryfor the repository and set itsENTITY_FIELDconstant to your entity's ID column. - Extend
AbstractAttributeProviderand register it inHyva\CmsLiveviewEditor\Model\ProviderPoolunder your content-type key (e.g.my_attribute). -
Add a schedule provider by declaring a
virtualTypeofAttributeScheduleProviderwith yourentityTypeargument (no new class needed), and register it in the pool'sscheduleProvidersargument: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> -
Add a save controller extending
AbstractAttributeSave, implementing the four hooks that wire your repositories, factory, entity-ID setter, and EAV type. - Add a compatibility rule for the new content type (see Attribute Compatibility Rules).
- Add admin UI for enabling attributes.
- 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.