원클릭으로
add-translatable-model
Make an existing Sylius Resource translatable, with TranslationType and Twig hooks
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Make an existing Sylius Resource translatable, with TranslationType and Twig hooks
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Implement the fix described in a GitHub issue and open a Pull Request that closes it
Apply the latest review feedback to a Pull Request and push it to the PR branch
Add an admin autocomplete form type for a Sylius Resource (translatable or not), optionally injected into another form via an extension
Add an admin FormType for an existing Sylius Resource
Add a Sylius admin grid for an existing Sylius Resource
Add a multiple images collection (OneToMany) to an existing Sylius Resource
| 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 |
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.
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.
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
}
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.
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.
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)
;
}
}
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.
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',
])
;
config/services.yamlAdd 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' }
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
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>
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" }
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
Same structure as create.yaml — replace every occurrence of .create. with .update..
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
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
bin/console cache:clear
bin/console sylius:debug:resource 'App\Entity\{ModelName}\{ModelName}' prints the main resource and references the translation class in its translation.classes.model rowbin/console sylius:debug:resource 'App\Entity\{ModelName}\{ModelName}Translation' prints the translation resourcebin/console doctrine:query:sql "DESCRIBE app_{model_snake}_translation" lists the expected columns (id, translatable_id, locale, plus translatable fields)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):
driver:
options:
class: "%app.model.{model_snake}.class%"
repository:
method: createListQueryBuilder
arguments: ["expr:service('sylius.context.locale').getLocaleCode()"]
type: string and do not set path: — the PHP getter delegates. Change sortable: ~ to sortable: translation.{field} (DQL path used by ORDER BY).options.fields: [translation.{field}] (DQL path used by WHERE).See /sylius-app:add-grid for the full grid syntax.
/sylius-app:add-grid/sylius-app:add-routes/sylius-app:add-menu