| name | add-translatable-model |
| description | Make an existing Sylius Resource translatable, with TranslationType and Twig hooks |
| argument-hint | [ModelName] |
| allowed-tools | AskUserQuestion, Bash, Read, Edit, Write, Glob, Grep |
Make a Sylius Resource translatable
Ask the user for the ModelName if not provided, and the translatable fields (the fields that vary per locale — they move from the main entity to the translation entity).
Prerequisites: /sylius-app:add-model and /sylius-app:add-form must have been run first.
How Sylius handles translatable entities
Sylius injects the OneToMany/ManyToOne relation between the entity and its translation via ORMTranslatableListener at runtime — do not map this relation manually. The listener also injects the locale field on the translation table.
1. Create the translation interface
src/Entity/{ModelName}/{ModelName}TranslationInterface.php:
<?php
declare(strict_types=1);
namespace App\Entity\{ModelName};
use Sylius\Resource\Model\ResourceInterface;
use Sylius\Resource\Model\TranslationInterface;
interface {ModelName}TranslationInterface extends ResourceInterface, TranslationInterface
{
}
2. Create the translation entity
src/Entity/{ModelName}/{ModelName}Translation.php:
<?php
declare(strict_types=1);
namespace App\Entity\{ModelName};
use Doctrine\ORM\Mapping as ORM;
use Sylius\Resource\Model\AbstractTranslation;
#[ORM\Entity]
#[ORM\Table(name: 'app_{model_snake}_translation')]
class {ModelName}Translation extends AbstractTranslation implements {ModelName}TranslationInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
public function getId(): ?int
{
return $this->id;
}
}
Do NOT declare $translatable, $locale, or the relation to the parent entity — Sylius injects these automatically.
3. Update the main entity
The entity already exists. Add TranslatableTrait + TranslatableInterface, remove the translatable fields' #[ORM\Column] annotations, and delegate their accessors to $this->getTranslation():
use Sylius\Resource\Model\TranslatableInterface;
use Sylius\Resource\Model\TranslatableTrait;
use Sylius\Resource\Model\TranslationInterface;
class {ModelName} implements {ModelName}Interface, TranslatableInterface
{
use TranslatableTrait {
__construct as private initializeTranslationsCollection;
}
public function __construct()
{
$this->initializeTranslationsCollection();
}
protected function createTranslation(): TranslationInterface
{
return new {ModelName}Translation();
}
}
Do NOT redeclare $translations — already defined in TranslatableTrait. If the entity has a constructor already (e.g. from relations added by add-model), merge the body into the existing constructor rather than duplicating.
4. Create the list-query repository
For admin grids to sort or filter on translated columns, the resource's repository must JOIN translations under the alias translation. Without it, Doctrine throws has no field named {field} whenever a sort/filter touches a translated field — display via PHP getter still works, hiding the bug.
src/Repository/{ModelName}Repository.php:
<?php
declare(strict_types=1);
namespace App\Repository;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\ResourceBundle\Doctrine\ORM\EntityRepository;
final class {ModelName}Repository extends EntityRepository
{
public function createListQueryBuilder(string $locale): QueryBuilder
{
return $this->createQueryBuilder('o')
->addSelect('translation')
->leftJoin('o.translations', 'translation', 'WITH', 'translation.locale = :locale')
->setParameter('locale', $locale)
;
}
}
5. Create the TranslationType
src/Form/Type/{ModelName}/{ModelName}TranslationType.php:
<?php
declare(strict_types=1);
namespace App\Form\Type\{ModelName};
use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType;
use Symfony\Component\Form\FormBuilderInterface;
final class {ModelName}TranslationType extends AbstractResourceType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
;
}
public function getBlockPrefix(): string
{
return 'app_{model_snake}_translation';
}
}
Labels for translation fields share the same app.form.{model_snake}.* namespace as the main FormType — the resource is the same, the field lives there regardless of whether it's translatable.
6. Update the main FormType
The {ModelName}Type already exists. Replace the translatable fields with a ResourceTranslationsType:
use Sylius\Bundle\ResourceBundle\Form\Type\ResourceTranslationsType;
$builder
->add('translations', ResourceTranslationsType::class, [
'entry_type' => {ModelName}TranslationType::class,
'label' => 'sylius.ui.translations',
])
;
7. Register services in config/services.yaml
Add the translation form type:
services:
app.form.type.{model_snake}_translation:
class: App\Form\Type\{ModelName}\{ModelName}TranslationType
arguments:
- '%app.model.{model_snake}_translation.class%'
- ['sylius']
tags:
- { name: form.type }
The ResourceFormComponent provides the live-component wiring consumed by the create.content hook below. Register it only if it doesn't already exist:
bin/console debug:container --tag=sylius.live_component.admin | grep 'app:{model_snake}:form'
If the command returns nothing, append to config/services.yaml:
services:
app.twig.component.{model_snake}.form:
class: Sylius\Bundle\UiBundle\Twig\Component\ResourceFormComponent
autoconfigure: false
arguments:
- '@app.repository.{model_snake}'
- '@form.factory'
- '%app.model.{model_snake}.class%'
- App\Form\Type\{ModelName}\{ModelName}Type
tags:
- { name: sylius.live_component.admin, key: 'app:{model_snake}:form' }
8. Update the resource config
Edit config/packages/sylius_resource.yaml. Add the translation: block to the existing app.{model_snake} entry, add form: under classes: if not already there, and declare the custom repository: from step 4:
sylius_resource:
resources:
app.{model_snake}:
driver: doctrine/orm
classes:
model: App\Entity\{ModelName}\{ModelName}
interface: App\Entity\{ModelName}\{ModelName}Interface
form: App\Form\Type\{ModelName}\{ModelName}Type
repository: App\Repository\{ModelName}Repository
translation:
classes:
model: App\Entity\{ModelName}\{ModelName}Translation
interface: App\Entity\{ModelName}\{ModelName}TranslationInterface
form: App\Form\Type\{ModelName}\{ModelName}TranslationType
9. Templates
Sylius renders translation forms with accordion-style locale tabs via the translations.with_hook macro. Without the hookable templates below, fields are rendered without locale tabs and potentially twice.
All templates live under templates/admin/{model_snake}/ at the project root.
templates/admin/{model_snake}/form/sections/general.html.twig:
<div class="card mb-3">
<div class="card-header">
<div class="card-title">{{ 'sylius.ui.general'|trans }}</div>
</div>
<div class="card-body">
<div class="row">
{% hook 'general' %}
</div>
</div>
</div>
templates/admin/{model_snake}/form/sections/translations.html.twig:
{% import '@SyliusAdmin/shared/helper/translations.html.twig' as translations %}
{% set form = hookable_metadata.context.form %}
{% set prefixes = hookable_metadata.prefixes %}
<div class="card mb-3">
<div class="card-header">
<div class="card-title">{{ 'sylius.ui.translations'|trans }}</div>
</div>
<div class="card-body">
{{ translations.with_hook(form.translations, prefixes, null, { accordion_flush: true }) }}
</div>
</div>
One template per non-translatable field at templates/admin/{model_snake}/form/sections/general/{field_name}.html.twig:
{{ form_row(hookable_metadata.context.form.{field_name}) }}
One template per translatable field at templates/admin/{model_snake}/form/sections/translations/{field_name}.html.twig:
{% set form = hookable_metadata.context.form %}
<div class="col-12">
{{ form_row(form.{field_name}) }}
</div>
10. Twig hooks
Hook files are split by action (create.yaml, update.yaml), one directory per resource — matching Sylius core convention.
Unified path: config/packages/twig_hooks/{model_snake}/create.yaml and update.yaml.
First-time setup: add (or extend) an imports: block at the top of config/packages/_sylius.yaml so the split files are loaded:
imports:
- { resource: "twig_hooks/**/*.yaml" }
create.yaml
sylius_twig_hooks:
hooks:
'sylius_admin.{model_snake}.create.content':
form:
component: 'app:{model_snake}:form'
props:
resource: '@=_context.resource'
form: '@=_context.form'
template: '@SyliusAdmin/shared/crud/common/content/form.html.twig'
priority: 0
'sylius_admin.{model_snake}.create.content.form.sections':
general:
template: 'admin/{model_snake}/form/sections/general.html.twig'
priority: 100
translations:
template: 'admin/{model_snake}/form/sections/translations.html.twig'
priority: 0
'sylius_admin.{model_snake}.create.content.form.sections.general':
default:
enabled: false
'sylius_admin.{model_snake}.create.content.form.sections.translations':
update.yaml
Same structure as create.yaml — replace every occurrence of .create. with .update..
11. Generate and apply the migration
bin/console doctrine:migrations:diff
Always review the generated migration before applying. doctrine:migrations:diff captures every difference between mapping and DB — including pre-existing schema drift unrelated to your translatable model. The migration should contain only:
CREATE TABLE app_{model_snake}_translation with translatable_id FK, locale column, and a unique constraint on (translatable_id, locale).
ALTER TABLE app_{model_snake} dropping the former translatable columns (no-op if the main table was never migrated with them).
If unrelated SQL is present (e.g. on Sylius core tables), trim the migration — drift belongs to the project baseline, not your skill output.
Apply:
bin/console doctrine:migrations:migrate --no-interaction
12. Translation keys
Get the project's default locale:
bin/console debug:container --parameter=kernel.default_locale
Add the field labels under app.form.{model_snake}.* in translations/messages.{locale}.yaml. The namespace is shared between non-translatable (main form) and translatable (translation form) fields — one entry per field regardless of which form it lives in:
app:
form:
{model_snake}:
name: Name
enabled: Enabled
title: Title
description: Description
13. Clear cache
bin/console cache:clear
14. Verify
Heads-up for existing grids
If a grid was already configured for this resource before making it translatable, the grid needs three tweaks (display via PHP getter still works, but sort/filter break silently):
- Grid driver must call the locale-aware list builder so translations are joined:
driver:
options:
class: "%app.model.{model_snake}.class%"
repository:
method: createListQueryBuilder
arguments: ["expr:service('sylius.context.locale').getLocaleCode()"]
- Field for a translatable column: keep
type: string and do not set path: — the PHP getter delegates. Change sortable: ~ to sortable: translation.{field} (DQL path used by ORDER BY).
- Filter targeting a translatable column:
options.fields: [translation.{field}] (DQL path used by WHERE).
See /sylius-app:add-grid for the full grid syntax.
Next steps
- Add admin grid → run
/sylius-app:add-grid
- Add admin routes → run
/sylius-app:add-routes
- Add admin menu → run
/sylius-app:add-menu