Modules
A module is an editable element inside a content layout: a heading, a picture, a call to action, or a more specialized component. Each instance stores its own values in the content version. The module definition provides the form and the templates that interpret those values.
Start with Configure modules for a minimal hero. This guide adds a reusable rating module and explains how to extend its form and evolve its stored data.
Create a rating module
Create this directory in the application's CMS collection:
cms/modules/rating/
├── config.yaml
├── form.html.twig
├── render.html.twig
└── translations/
└── sfs_cms_modules.en.yaml
The example uses a plain title and a score from one to five. It deliberately keeps the heading level fixed, so editors can change the content without changing the page's heading hierarchy.
# cms/modules/rating/config.yaml
module:
revision: 1
group: content
compatible_contents: [page]
form_template: '@module/rating/form.html.twig'
module_options:
form_fields:
title:
type: text
type_options:
required: true
score:
type: choice
type_options:
choices:
'1 star': 1
'2 stars': 2
'3 stars': 3
'4 stars': 4
'5 stars': 5
render_template defaults to @module/rating/render.html.twig. The dynamic module form resolves text and choice to Symfony form types and passes type_options to them.
{# cms/modules/rating/form.html.twig #}
{{ form_row(form.title) }}
{{ form_row(form.score) }}
{{ form_rest(form) }}
{# cms/modules/rating/render.html.twig #}
<section class="rating">
<h2>{{ title|default('') }}</h2>
{% if score|default(false) %}
<p class="rating__score">{{ score }} / 5</p>
{% endif %}
</section>
Normal Twig escaping applies to the title. Add the rating styles to your application frontend build.
# cms/modules/rating/translations/sfs_cms_modules.en.yaml
rating:
label: Rating
prototype_button: Rating
prototype_button_icon: '<i class="bi bi-star"></i>'
form:
title.label: Title
score.label: Score
Clear the Symfony cache, open a page with a container that permits the module, insert Rating, and save a draft. Preview the page before publishing. If the module is absent from the picker, check the content's allowed_modules and the module's compatible_contents.
Choose field types
Use Symfony field names such as text, textarea, choice, checkbox, integer, url, collection, and entity. Their usual Symfony options apply. An entity field also needs its entity class; a collection needs its entry_type.
CMS-specific fields provide editing and reference tracking:
| Field | Purpose |
|---|---|
id, class, color, css |
HTML presentation values |
html |
Rich editorial content |
blockInstance, blockStatic |
Select an editable block record or a static block type |
media, mediaVersion |
Select a media object, version, or picture |
symfonyRoute |
Select a Symfony route and its parameters |
translatable |
Wrap a field with values for each enabled locale |
dynamicForm, dynamicFormCollection |
Define nested data structures |
See the dedicated guides for media, media modal, Symfony routes, links, and translatable fields. Their stored values differ from plain strings; use their rendering helpers rather than concatenating database identifiers into URLs.
Use a custom Symfony form type
A form type in App\Form\Type can be selected by its short name. For example, ratingScore resolves to App\Form\Type\RatingScoreType:
<?php
// src/Form/Type/RatingScoreType.php
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class RatingScoreType extends AbstractType
{
public function getParent(): string
{
return ChoiceType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => array_combine(range(1, 5), range(1, 5)),
]);
}
}
Register the type through Symfony's normal App\ service discovery with autoconfiguration enabled. Then replace the score field definition with:
module:
module_options:
form_fields:
score:
type: ratingScore
This is an override fragment of the existing module configuration. For custom namespaces or an entirely custom module form, see Dynamic Form Type. Use module_type and module_options when the whole module needs a dedicated form type.
Customize the editor preview
Set edit_template to a Twig template when the generic editing form is insufficient. The editor template receives a Symfony form view. Render values from form.<field>.vars.data; keep form field rendering in form_template.
CMS editor assets can synchronize fields with a preview through paired attributes such as data-edit-content-input and data-edit-content-target. Use the same identifier on both sides. Rich text, translated fields, and media have additional widgets; inspect a bundled module with the same field type before extending their inline behavior.
An editor preview is separate from the public render_template. Check both the editing screen and the public preview when changing the module's design.
Override a bundled module
Add a directory with the same module identifier to the application's later-loaded CMS collection. Override only the files and configuration values that need to change. For example, a custom render.html.twig can change a button's markup without replacing its form.
Collection order matters: later collections override earlier ones. Clear the Symfony cache after changing definitions. Recompile already published content when the public render template changes; clearing the Symfony cache alone does not replace stored compiled HTML.
Migrate stored values
Increase revision when changing the saved data shape and add migrate.php. The script returns a callable that receives the stored data, its previous revision, and the target revision:
<?php
// cms/modules/rating/migrate.php
return static function (array $data, int $fromRevision, int $toRevision): array {
if ($fromRevision < 2 && array_key_exists('label', $data)) {
$data['title'] = $data['label'];
unset($data['label']);
}
return $data;
};
Set revision: 2 for this change. The CMS sets _revision after running migration scripts. Preserve unrelated keys, make conversions repeatable, and test data from every revision still present in your database. Increasing the number alone does not convert an old label field into title.
Configured media selection migrations
Module migration scripts can receive optional fourth and fifth arguments:
CmsConfig $cmsConfig and MediaTypesCollection $mediaTypesCollection. Older
scripts remain valid. Both editor data mapping and module rendering supply this
context.
MediaSelectionMigrator::migrate($data, $formFields, $mediaTypesCollection)
repairs restricted mediaVersion fields, including translations and dynamic
collections, using the first available choice in configuration order. Valid
selections and unrelated data remain untouched. A configured picture name is
never replaced with a hard-coded _default; unresolved references or empty
allow-lists are preserved for editorial resolution.
Use a matching Media Bundle version that provides MediaSelectionChoices alongside these migrations.
Module site filters
Module site filters accept persisted site entities as well as site identifiers
and boolean maps. Saving a module through a site selection field therefore
keeps the same rendering behaviour when its data is hydrated again.