Skip to content

Extending Hyvä CMS for Other Content Types

Advanced topic

Extending Hyvä CMS for other content types should only be attempted by experienced developers. Be prepared to debug cases specific to your content type integration.

Susceptible to breaking changes

This integration area may be particularly susceptible to breaking changes in future releases. When implementing custom content type integrations, be prepared to update your code when upgrading to new versions of Hyvä CMS.

This page is a general reference for extending Hyvä CMS for other content types, such as blog posts or landing pages from third-party modules. A lot of custom logic will be specific to your own implementation, but the steps below give you a good starting point. See the Hyva_CmsMagento module for a concrete example.

Step 1: Understanding Core Concepts

The Hyvä CMS Liveview editor provides an alternative content creation and editing experience to Magento's Page Builder. Hyvä CMS can be enabled:

  • Per individual content item.
  • For all content of a specific type (e.g., all CMS pages).
  • For all content types.

The editor identifies content through two key parameters:

  • type: Identifies the type of content being edited (e.g., cms_page, cms_block).
  • id: The unique identifier of the specific content item.

When creating new content, id is set to 0, signalling that a new record for the specified entity type should be created.

Hyvä CMS stores both a draft and a published version of the content.

Step 2: Name and Create Your Module

Choose a unique entity_type identifier for your content type. Following convention, this identifier should match the name of the database table you're extending Liveview for, for example cms_page or cms_block.

Create a dedicated module to implement your Liveview content type integration. To follow naming conventions, it should be named Vendor_HyvaCms[ContentTypeName] (e.g., Namespace_HyvaCmsBlog).

Step 3: Setup Database Tables, Models, and Interfaces

Add the necessary db_schema.xml, db_schema_whitelist.json, Model, and API Interface classes to your module. See etc/db_schema.xml in the Hyva_CmsMagento module for an example.

A table is required to store the main Hyvä CMS content and its properties, along with another for version history (e.g., hyva_commerce_cms_block and hyva_commerce_cms_page_version_history).

Tables should follow the column naming conventions used in Hyva_CmsMagento, especially for version history tables.

Step 4: Admin Settings and UI

4.1 System Config

Add a system configuration setting to enable or disable Hyvä CMS Liveview for your content type, using the config path hyva_commerce_cms/[your_content_type]/enabled. This setting controls the global availability of Liveview for the content type.

4.2 Content Listing Page

On your content listing page, when Liveview is enabled:

  1. Add a Yes/No column for the is_liveview_enabled status.
  2. Optionally add an action to open new content in the Liveview editor.

4.3 Content Form

On your content form, when Liveview is enabled:

  1. Add a toggle for is_liveview_enabled.
  2. Add a button/link to open the Liveview editor.
  3. (Optional) Add a preview URL field if implementing custom previews.

4.4 Automatic CSP Frame Policies

Automatic CSP Frame Policies are available from Hyvä CMS 1.0.2.

To enable Automatic CSP Frame Policies for multi-domain setups without manual configuration, add new routes that display the preview (e.g. the content form from section 4.3) to your module's etc/adminhtml/di.xml file:

<type name="Hyva\CmsLiveviewEditor\Model\Security\IsValidAdminPreviewRequest">
    <arguments>
        <argument name="allowedRoutes" xsi:type="array">
            <item name="blog/page/edit" xsi:type="string">blog/page/edit</item>
        </argument>
    </arguments>
</type>

An example of this can be found in:

vendor/hyva-themes/commerce-module-cms/src/magento-cms/etc/adminhtml/di.xml

Note

Registering these routes is required to support Automatic CSP Frame Policies for multi-domain setups without manual configuration. Otherwise, Magento instances running their admin area in CSP strict mode (as opposed to report-only mode) won't be able to display the preview.

4.5 Save Logic

Add logic to update the data for your Liveview entity once the form is saved. See Hyva_CmsMagento::Observer/CmsPageSaveAfter.php for an example.

Note

After completing this step, you will see the following error on the content form page and when you try to open the Liveview editor. Step 5 fixes it.

No content provider found for type: [name of your content type]

4.6 Access Control (ACL)

Per-content-type ACL groups are available from Hyvä CMS 1.3.0. Earlier releases gated every Hyvä CMS surface with Magento_Backend::content.

Give your content type its own ACL resources so admin roles can grant it verb by verb:

  1. In your module's etc/acl.xml, declare a group for your entity nested under Hyva_Cms::cms, with one ACL resource per verb you support, such as Vendor_Module::thing_view, Vendor_Module::thing_save and Vendor_Module::thing_publish.
  2. Register your content type with the resolver in your module's global etc/di.xml:

    <type name="Hyva\CmsLiveviewEditor\Model\Security\ContentTypeAuthorization">
        <arguments>
            <argument name="resources" xsi:type="array">
                <item name="thing" xsi:type="string">Vendor_Module::thing</item>
            </argument>
            <argument name="labels" xsi:type="array">
                <item name="thing" xsi:type="string">thing</item>
            </argument>
        </arguments>
    </type>
    
  3. Give each of your own admin controllers a literal ADMIN_RESOURCE naming the verb it performs, such as public const ADMIN_RESOURCE = 'Vendor_Module::thing_save';.

The editor, version history, scheduling and Import/Export then check your ACL resources with no more code. A content type you leave unregistered falls back to Magento_Backend::content for every verb and logs a warning to var/log/hyva_cms.log.

See ACL Resources in a Hyvä CMS Package for the verb list, sort orders and where each surface is gated.

Step 5: Create a Liveview Provider

Create a provider class that implements Hyva\CmsLiveviewEditor\Api\ProviderInterface. This class handles the content type-specific logic for Hyvä CMS. Register your provider in etc/di.xml to make it available to the provider pool:

<type name="Hyva\CmsLiveviewEditor\Model\ProviderPool">
    <arguments>
        <argument name="contentProviders" xsi:type="array">
            <item name="cms_page" xsi:type="object">Hyva\CmsMagento\Model\LiveviewCmsPageProvider</item>
        </argument>
    </arguments>
</type>

It is worth reviewing how the ProviderInterface is implemented in Hyva_CmsMagento, as much of the logic may be adapted for your use:

  • Hyva\CmsMagento\Model\Provider\CmsBlockProvider
  • Hyva\CmsMagento\Model\Provider\CmsPageProvider

Tailwind CSS Provider Methods

ProviderInterface includes methods that let your content type participate in Tailwind CSS generation. The most important is getScopeSelector(), which returns the CSS scope selector applied to your entity's rendered content:

public function getScopeSelector(int $entityId): string
{
    return '.hcms-blog-' . $entityId;
}

Return a unique, dot-prefixed selector following the convention .hcms-{your-type}-{entityId}. Hyvä CMS wraps your rendered content in this selector so generated styles stay scoped to the entity.

getScopeSelector() is required (breaking change)

getScopeSelector() is a required ProviderInterface method from Hyvä CMS 1.2.0. Custom content-type providers written for older Hyvä CMS versions must add it before they will compile. The previous getTailwindClassPrefix() method is deprecated and no longer used for style isolation, but it remains in the interface, so keep it implemented (a simple stub is fine).

Implement the remaining Tailwind methods (isTailwindJitEnabled(), getTailwindTableName(), and the column-name getters) to point at a per-entity, per-theme, per-edition CSS table. A provider that does not use Tailwind JIT can return false from isTailwindJitEnabled() and stub the others.

Step 6: Add Settings to the Liveview Editor

Core settings handle non-content-related properties and enable the creation of new entities directly within the Hyvä CMS Liveview editor.

In liveview_editor.xml, add a core-settings.[your content entity type] block to the core-settings block. It will require a ViewModel/Block to get form data, as well as an AJAX controller and model to handle saving the form data. Set the controller's ADMIN_RESOURCE to your save ACL resource, see 4.6 Access Control (ACL). Include both frontend and backend form validation.

Use liveview_editor.xml instead of liveview_editor_index.xml to ensure compatibility with all the Liveview editor functionality, e.g. scheduling.

Target a single entity type with its own layout handle

The editor applies a {entity_type}_liveview_editor layout handle in addition to the shared liveview_editor handle. Use it for layout updates that should only apply when editing your content type. For an entity type of blog_post, the handle is blog_post_liveview_editor. Available since Hyvä CMS 1.2.0.

For example:

<referenceBlock name="core-settings">
    <block name="core-settings.cms_page" template="Hyva_CmsMagento::core-settings/page-form.phtml"/>
</referenceBlock>

Your block's template should dispatch two JavaScript events:

  1. On initialization, set the content label:

    window.dispatchEvent(new CustomEvent('set-content-label', {
        detail: { label: '<?= $escaper->escapeHtml(__('Page')) ?>' }
    }));
    
  2. After saving the entity, notify listeners:

    window.dispatchEvent(new CustomEvent('after-content-entity-saved', {
        detail: {
            entity_id: this.entityId,
            entity_type: this.entityType,
            is_active: this.formData.is_active ? 1 : 0,
            is_tailwindcss_jit_enabled: this.formData.is_tailwindcss_jit_enabled ? 1 : 0,
            disabled_notice_message: this.disabledNoticeMessage
        }
    }));
    

Step 7: Handle Liveview Display Logic

Your Liveview provider handles most of the Hyvä CMS specific logic. In your own module, however, you need to add logic to determine when to show Hyvä CMS content instead of Page Builder content, based on system configuration and content-specific settings.

In Hyva_CmsMagento this is handled with plugin classes:

<type name="Magento\Cms\Api\Data\PageInterface">
    <plugin name="Hyva_CmsMagento::change_cms_page_content" type="Hyva\CmsMagento\Plugin\Model\Page" />
</type>
<type name="Magento\Cms\Api\Data\BlockInterface">
    <plugin name="Hyva_CmsMagento::change_cms_block_content" type="Hyva\CmsMagento\Plugin\Model\Block" />
</type>

Step 8: Add Navigator Support

The navigator slideout provides quick access to browse and create content items. To add support for your content type:

8.1 Create List Controller

Create an admin controller that returns a JSON list of your entities. The controller should:

  • Declare a literal ADMIN_RESOURCE set to your view ACL resource, such as Vendor_Module::thing_view
  • Accept pageSize and currentPage parameters
  • Support search filtering by identifier/title
  • Use StoreDataProvider to format store data
  • Generate liveview_url with format: liveview/editor/index/type/{entity_type}/id/{id}/
  • Return JSON: { total_count: int, items: array } where each item contains: id, value, name, liveview_url, stores

Reference implementation: local-src/module-cms/src/liveview-editor/Controller/Adminhtml/Link/CmsBlocks.php

8.2 Create Listings Template

Create a template at view/adminhtml/templates/listings/{entity_type}.phtml that:

  • Uses Alpine.js to fetch and display entities grouped by store
  • Listens for the @listings-slideout-tab.window event with event.detail.tab === '{entity_type}' to trigger data loading
  • Implements search functionality
  • Includes a "Create New" link pointing to liveview/editor/index/type/{entity_type}/id/0/

Reference implementation: local-src/module-cms/src/magento-cms/view/adminhtml/templates/listings/block.phtml

8.3 Register in Layout

Register your listings template in view/adminhtml/layout/liveview_editor.xml.

Reference implementation: local-src/module-cms/src/magento-cms/view/adminhtml/layout/liveview_editor.xml

Step 9: (Optional) Add Scheduling Support

If you want your custom content type to support scheduling functionality, you'll need to implement additional interfaces and handle scheduled content rendering. Scheduling support for custom content types is available from Hyvä CMS 1.0.2.

Required Changes for Scheduling

  1. Provider Interface Updates: Include the scheduledItemId parameter in your Hyva\CmsLiveviewEditor\Api\ProviderInterface::getStoreContentData() method implementation to support scheduling.

  2. Implement ScheduleProviderInterface: Your content type provider must implement Hyva\CmsScheduling\Api\ScheduleProviderInterface to enable scheduling capabilities.

  3. Handle Scheduled Content URL Parameter: Update your provider to handle the scheduled_item URL parameter when rendering scheduled content. See Hyva\CmsMagento\Plugin\Model\Page for implementation reference.

Step 10: (Optional) Extend with Custom Components

To add custom components for your content type, see Creating Components. For how the root settings below combine, see Controlling Root Components.

Restricting which components can be added as roots

Components can declare context_flags in their JSON declaration. When your entity emits the init-content-properties event, pass allowed_root_component_context_flags to allow only components carrying a matching flag as top-level (root) components for your content type. Feature modules can also push flags onto Alpine.store('global').disallowedRootComponentContextFlags to hide flagged components from the picker. This is how contexts such as menus or email templates limit their root component choices. Available since Hyvä CMS 1.1.0.

Limiting how many components can sit at the root

Send max_root_components on the same init-content-properties event, alongside allowed_root_component_context_flags. Once the document root holds that many children, an add landing at the root is refused with an error message, and root-level drops and pastes are refused the same way, so the refusal reads the same however the add was started. The count comes from the rendered tree, so it stays accurate between saves. Content types that don't send the key keep unlimited roots. The form builder sends max_root_components: 1 so a form holds exactly one root. Available since Hyvä CMS 1.3.0.

For exclusions throughout the editor, include disallowed_components in the init-content-properties event detail. It is an array of component names, for example ['html', 'product_slider']. It applies to every Add a Component insertion point, header quick-add and paste. Unlike children.config.excludes, it also applies below unrestricted nested containers. Editors that omit it keep their existing choices. This is an editor restriction; custom persistence and API paths must still enforce their own content rules.

Giving a component the protected root header

Set protected_root: true in a component's components.json declaration, alongside root_only, singleton, and hidden. The component declaration schema validates the key. For the concept, see Protected Roots. Available since Hyvä CMS 1.3.0.

With it set, the editor stops drawing that component as a tree row and draws it as the components panel's header row instead, the cap: the component's declared icon and label, and an edit pencil that opens its property panel. The component's children become the top level of the tree and stay fully editable, they can be reordered, selected, copied, and deleted. The cap itself can't be dragged, selected, deleted, copied, duplicated, or hidden, and the root limit becomes one.

The panel's bottom Add button adds into the cap. When the cap accepts exactly one child type, the add row and that button read "Add

Erase rebuilds the root from its declaration defaults, so a content type that keeps settings on its root should remove both the Erase button (elements.clear) and the Clear Canvas menu item (options.clear) in its own layout handle, as the product attribute editor does.

protected_root is all or nothing per tree

One ordinary, unlocked top-level component turns the cap off for the whole tree, no cap is drawn. Pair protected_root with root_only: true and a max_root_components of 1 so nothing else can land at the root.

The icon must be a real SVG, in Vendor_Module::path/to/icon.svg form. The cap renders it as an <img> and recolors it with a CSS filter, so the emoji icon object form and multi-color SVGs don't work.

The older per-content-type rootComponent key, the contentTypes di.xml argument on Hyva\CmsLiveviewEditor\ViewModel\EditorConfig, keeps working and resolves through the same editor helper. Prefer the declaration key when the root belongs to the component rather than to the content type, which is what lets a third-party root get the cap with no plugin and no di.xml.

Product attributes get their cap from locked set on the stored node, not from protected_root.

Step 11: Testing and Validation

As this is an advanced integration, thorough testing is critical:

  1. Test your implementation thoroughly, including:

    • Content creation and editing
    • Enabled vs disabled content
    • Multi store view support
    • Draft and published workflow
    • Version history
    • Form validation
    • Content display logic
  2. Be prepared to debug cases specific to your content type

Step 12: (Optional) Register with Import/Export

The Import/Export feature is handler-driven, so any module can make its own content type importable and exportable. There is no need to touch the Import/Export module itself, and a registered handler appears automatically in the export picker, JSON tools, and ZIP packages. Available since Hyvä CMS 1.2.0.

How the Content Type Handler System Works

Each content type is backed by a class implementing the @api interface Hyva\CmsImportExport\Model\ContentType\ContentTypeHandlerInterface. Handlers are registered in a single shared pool, Hyva\CmsImportExport\Model\ContentType\HandlerPool, keyed by a type string such as cms_page or template. The import/export module ships handlers for cms_page, cms_block, instance_component, product_attribute, and category_attribute; the Hyva_CmsTemplate module contributes template and snippet the same way an integrator would.

Most handlers extend the convenience base class Hyva\CmsImportExport\Model\ContentType\AbstractContentTypeHandler, which provides sensible defaults (for example isExportable() returns true) so you only implement what differs.

The Content Type Handler Interface Methods

ContentTypeHandlerInterface declares:

Method Purpose
getLabel(): string Human-readable label shown in the Import/Export UI.
getEntityList(string $search = '', int $limit = 50): array Selectable entities. Each item is ['id', 'label', 'identifier'], plus an optional 'stores' array for store-scoped types.
getEntityData(int $entityId): array Portable entity metadata written to the package (use portable identifiers, not auto-increment IDs).
getProviderType(): ?string Liveview provider key (e.g. cms_page), or null for types that do not use the liveview provider.
getExportFilename(array $entityData): string Filename-safe identifier used for ZIP naming.
isExportable(int $entityId): bool Whether the entity may be exported. Default true; override to hide entities without portable content.
resolveEntityId(array $entityData): ?int Resolve a previously exported entity back to a local ID, or null if it does not exist here.
createEntity(array $entityData): int Create a copy for Import as new and return its ID.
saveContent(int $entityId, array $draftContent, array $publishedContent): void Persist liveview content into an existing entity.
updateEntity(int $entityId, array $entityData): void Persist entity-level metadata after content is saved. Default: no-op.
normalizeContentForExport(array $content): array Translate local IDs to portable keys before export. Default: no-op.
normalizeContentForImport(array $content): array Translate portable keys back to local IDs on import. Default: no-op.
getImportWarnings(): array Warnings collected by the last normalizeContentForImport() call (e.g. unresolved references).

Controlling Which Entities Are Exportable

The isExportable() method gates both the UI and the server. The CMS page and block handlers override it to return true only when the entity carries Hyvä CMS content, which is why non-Hyvä pages and blocks never appear in the picker and are rejected if requested directly. Override it whenever an entity can exist without portable content.

Registering a Content Type Handler

Add your handler to the shared HandlerPool in your module's etc/di.xml. Magento merges the array, so you only declare your own entries:

<type name="Hyva\CmsImportExport\Model\ContentType\HandlerPool">
    <arguments>
        <argument name="handlers" xsi:type="array">
            <item name="my_type" xsi:type="object">My\Module\Model\ImportExport\MyHandler</item>
        </argument>
    </arguments>
</type>

Declare a module sequence on Hyva_CmsImportExport in your etc/module.xml so the pool is available, then implement the handler. Extending AbstractContentTypeHandler keeps it small:

namespace My\Module\Model\ImportExport;

use Hyva\CmsImportExport\Model\ContentType\AbstractContentTypeHandler;

class MyHandler extends AbstractContentTypeHandler
{
    public function getLabel(): string
    {
        return (string) __('My Content Type');
    }

    public function getEntityList(string $search = '', int $limit = 50): array
    {
        // return [['id' => 1, 'label' => '...', 'identifier' => 'portable-key'], ...]
    }

    public function getEntityData(int $entityId): array
    {
        // portable metadata, keyed by a stable identifier rather than the numeric ID
    }

    public function resolveEntityId(array $entityData): ?int
    {
        // look up the local entity by its portable identifier, or null
    }

    public function createEntity(array $entityData): int
    {
        // create a copy for "Import as new" and return its ID
    }
}

The template and snippet handlers in Hyva_CmsTemplate (Hyva\CmsTemplate\Model\ImportExport\TemplateHandler and SnippetHandler) are concise, real-world references: each keys its portable identifier off the template or snippet name and persists a single content blob through the template provider.

The Import/Export is driven by adminhtml controllers rather than a public REST endpoint, so a handler is reached through the editor UI. Registering it in the pool is all that is required for it to participate in both ZIP and JSON transfers.