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
{
// declare getters and setters for each translatable field
}
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;
// translatable fields with ORM\Column + getters + setters
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();
}
// translatable fields: remove ORM\Column from the property, delegate to getTranslation()
// public function getName(): ?string { return $this->getTranslation()->getName(); }
// public function setName(?string $name): void { $this->getTranslation()->setName($name); }
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
// add translatable fields with labels
// ->add('title', TextType::class, ['label' => 'app.form.{model_snake}.title'])
;
}
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
// keep non-translatable fields
->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 # Sylius-Standard's `_defaults: autoconfigure: true` makes Symfony UX try to auto-name this component before Sylius's compiler pass tags it
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
# one entry per non-translatable field:
# {field_name}:
# template: 'admin/{model_snake}/form/sections/general/{field_name}.html.twig'
# priority: 0
'sylius_admin.{model_snake}.create.content.form.sections.translations':
# one entry per translatable field:
# {field_name}:
# template: 'admin/{model_snake}/form/sections/translations/{field_name}.html.twig'
# priority: 0
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}_translationwithtranslatable_idFK,localecolumn, 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 # non-translatable, lives on the main form
enabled: Enabled
title: Title # translatable, lives on the translation form
description: Description
13. Clear cache
bin/console cache:clear
14. Verify
-
bin/console sylius:debug:resource 'App\Entity\{ModelName}\{ModelName}'prints the main resource and references the translation class in itstranslation.classes.modelrow -
bin/console sylius:debug:resource 'App\Entity\{ModelName}\{ModelName}Translation'prints the translation resource -
bin/console doctrine:query:sql "DESCRIBE app_{model_snake}_translation"lists the expected columns (id,translatable_id,locale, plus translatable fields)
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: stringand do not setpath:— the PHP getter delegates. Changesortable: ~tosortable: translation.{field}(DQL path used byORDER BY). - Filter targeting a translatable column:
options.fields: [translation.{field}](DQL path used byWHERE).
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