Add a CMS element from a theme
A CMS element is a reusable content block the admin can drop onto a CMS page: a hero, a feature grid, a testimonial slider. hostware ships several core ones, and a theme can add its own by dropping a PHP class under Cms/Elements/ plus three view files. No registration wiring is needed. The element becomes selectable in the admin as soon as a sales channel using your theme loads a CMS page.
The evolution theme uses this mechanism for its evo_* elements (header, numbers, feature boxes, testimonials, and so on) and is the reference implementation to copy from.
The four files you need
Every CMS element is made of a PHP class and three view files with a matching naming pattern.
custom/themes/hw/mytheme/
├── Cms/
│ └── Elements/
│ └── My_Hero.php the element class
└── resources/
└── views/
└── cms/
└── elements/
├── my_hero.twig storefront output
├── my_hero_preview.blade.php admin preview thumbnail
└── my_hero_previewConfig.blade.php admin config formNames have to line up. The PHP class file uses PascalCase with an underscore prefix that matches the element name in lowercase (My_Hero => my_hero). Twig and Blade files use the snake_case form.
The element class
Every element implements App\Framework\Core\Cms\CmsElementInterface. Six methods, all thin.
<?php
// custom/themes/hw/mytheme/Cms/Elements/My_Hero.php
namespace custom\themes\hw\mytheme\Cms\Elements;
use App\Framework\Core\Cms\CmsElementInterface;
class My_Hero implements CmsElementInterface {
public function getName(): string {
return "my_hero";
}
public function getLabel(): string {
return "My Hero";
}
public function getView(): string {
return "theme.mytheme::cms/elements/my_hero";
}
public function getPreview(): string {
return "theme.mytheme::cms/elements/my_hero_preview";
}
public function getDefaultConfig(): array {
return [
"variant" => "left",
"title" => "Welcome",
];
}
public function provideData(array $config): array {
return $config;
}
}The namespace has to match the folder path exactly, lowercased for vendor and name (custom\themes\hw\mytheme\Cms\Elements). Element discovery walks each active theme's Cms/Elements/ folder and instantiates every class that implements the interface, so a wrong namespace means the element silently never shows up.
How each method is used
getName()is the unique element id, stored on the CMS element database row. Never rename it after users have started using it, or their content disappears.getLabel()is what the admin sees in the element picker.getView()returns the twig template rendered on the storefront. Use thetheme.<name>::namespace so the resolver stays inside your theme.getPreview()returns the small preview rendered inside the CMS builder in the admin (Blade template).getDefaultConfig()seeds the config values when the admin first drops the element. Whatever keys you return here are the ones your storefront twig can safely read.provideData(array $config)receives the saved config and returns the data array made available to the twig template. Return$configas-is for a stateless element; hydrate with model data when the element needs live values (a product list, a category tree).
The storefront twig template
The template rendered by getView() receives everything provideData() returned as top-level variables. There is no wrapping block scope, so writing {{ config.title }} works when your provideData returned ['config' => $config], or {{ title }} when it returned ['title' => $config['title']].
{# resources/views/cms/elements/my_hero.twig #}
<section class="my-hero variant--{{ config.variant ?? 'left' }}">
<div class="hw-container">
{% if config.title %}
<h1>{{ config.title|raw }}</h1>
{% endif %}
</div>
</section>Wrap content in .hw-container when you want it to align with the rest of the storefront's 1200px content column. Leave it out for full-bleed sections.
The admin preview and config form
Both admin files are Blade, not Twig, because the admin runs on Blade views. Keep the preview file empty when the admin thumbnail is not important; keep the config file focused on the settings the admin actually needs.
{{-- my_hero_previewConfig.blade.php --}}
<div class="form-group">
<label>Variant</label>
<select class="form-control custom-select" name="config['variant']">
<option value="left" selected>Left</option>
<option value="centered">Centered</option>
</select>
</div>
<div class="form-group">
<label>Title</label>
<input type="text" class="form-control" name="config['title']" value="{{ $config['title'] ?? '' }}">
</div>Input names always use the config['key'] bracket form. Anything you write here posts into the config JSON that the admin saves on the element row, and that is what your provideData() receives on the next render.
Ship SCSS with the element
Elements can carry their own SCSS. Add a partial under public/css/elements/ and import it from your style.scss:
// public/css/style.scss
@import "elements/my_hero";The per-sales-channel compile picks up the new partial on the next branding save or on the next theme:watch tick.
Boolean checkboxes are only sent when they are checked. In the twig template read them with {% if config.my_flag is defined %}, never == true, unless getDefaultConfig() gives them a false default.