ワンクリックで
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-plugin:add-model and /sylius-plugin: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 $SYLIUS_NAMESPACE\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 $SYLIUS_NAMESPACE\Entity\{ModelName};
use Doctrine\ORM\Mapping as ORM;
use Sylius\Resource\Model\AbstractTranslation;
#[ORM\Entity]
#[ORM\Table(name: '${SYLIUS_PREFIX}_{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 $SYLIUS_NAMESPACE\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 $SYLIUS_NAMESPACE\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' => '${SYLIUS_PREFIX}.form.{model_snake}.title'])
;
}
public function getBlockPrefix(): string
{
return '${SYLIUS_PREFIX}_{model_snake}_translation';
}
}
Labels for translation fields share the same ${SYLIUS_PREFIX}.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:
${SYLIUS_PREFIX}.form.type.{model_snake}_translation:
class: $SYLIUS_NAMESPACE\Form\Type\{ModelName}\{ModelName}TranslationType
arguments:
- '%${SYLIUS_PREFIX}.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:
vendor/bin/console debug:container --tag=sylius.live_component.admin | grep '${SYLIUS_PREFIX}:{model_snake}:form'
If the command returns nothing, append to config/services.yaml:
services:
${SYLIUS_PREFIX}.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:
- '@${SYLIUS_PREFIX}.repository.{model_snake}'
- '@form.factory'
- '%${SYLIUS_PREFIX}.model.{model_snake}.class%'
- $SYLIUS_NAMESPACE\Form\Type\{ModelName}\{ModelName}Type
tags:
- { name: sylius.live_component.admin, key: '${SYLIUS_PREFIX}:{model_snake}:form' }
Edit config/resources/{model_snake}.yaml at the plugin root (the file created by /sylius-plugin:add-model). Add the translation: block to the existing ${SYLIUS_PREFIX}.{model_snake} entry, add form: under classes: if not already there, and declare the custom repository: from step 4:
sylius_resource:
resources:
${SYLIUS_PREFIX}.{model_snake}:
driver: doctrine/orm
classes:
model: $SYLIUS_NAMESPACE\Entity\{ModelName}\{ModelName}
interface: $SYLIUS_NAMESPACE\Entity\{ModelName}\{ModelName}Interface
form: $SYLIUS_NAMESPACE\Form\Type\{ModelName}\{ModelName}Type
repository: $SYLIUS_NAMESPACE\Repository\{ModelName}Repository
translation:
classes:
model: $SYLIUS_NAMESPACE\Entity\{ModelName}\{ModelName}Translation
interface: $SYLIUS_NAMESPACE\Entity\{ModelName}\{ModelName}TranslationInterface
form: $SYLIUS_NAMESPACE\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 plugin convention (config/twig_hooks/admin/{model_snake}/).
Unified path at the plugin root: config/twig_hooks/admin/{model_snake}/create.yaml and update.yaml.
First-time setup: add the import once to the plugin's config/config.yaml: - { resource: "twig_hooks/**/*.yaml" }.
sylius_twig_hooks:
hooks:
'sylius_admin.{model_snake}.create.content':
form:
component: '${SYLIUS_PREFIX}:{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: '${SYLIUS_TEMPLATE_NS}admin/{model_snake}/form/sections/general.html.twig'
priority: 100
translations:
template: '${SYLIUS_TEMPLATE_NS}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: '${SYLIUS_TEMPLATE_NS}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: '${SYLIUS_TEMPLATE_NS}admin/{model_snake}/form/sections/translations/{field_name}.html.twig'
# priority: 0
Same structure as create.yaml — replace every occurrence of .create. with .update..
The plugin's DI extension declares the DoctrineMigrations namespace via PrependDoctrineMigrationsTrait, so generate into it:
vendor/bin/console doctrine:migrations:diff --namespace=DoctrineMigrations
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 ${SYLIUS_PREFIX}_{model_snake}_translation with translatable_id FK, locale column, and a unique constraint on (translatable_id, locale).ALTER TABLE ${SYLIUS_PREFIX}_{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:
vendor/bin/console doctrine:migrations:migrate --no-interaction
Get the project's default locale:
vendor/bin/console debug:container --parameter=kernel.default_locale
Add the field labels under ${SYLIUS_PREFIX}.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:
${SYLIUS_PREFIX}:
form:
{model_snake}:
name: Name # non-translatable, lives on the main form
enabled: Enabled
title: Title # translatable, lives on the translation form
description: Description
vendor/bin/console cache:clear
vendor/bin/console sylius:debug:resource '$SYLIUS_NAMESPACE\Entity\{ModelName}\{ModelName}' prints the main resource and references the translation class in its translation.classes.model rowvendor/bin/console sylius:debug:resource '$SYLIUS_NAMESPACE\Entity\{ModelName}\{ModelName}Translation' prints the translation resourcevendor/bin/console doctrine:query:sql "DESCRIBE ${SYLIUS_PREFIX}_{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: "%${SYLIUS_PREFIX}.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-plugin:add-grid for the full grid syntax.
/sylius-plugin:add-grid/sylius-plugin:add-routes/sylius-plugin:add-menu