Skip to content

CreativeNative/translation-bundle

Repository files navigation

TMI Translation Bundle - Doctrine Entity Translations for Symfony

CI codecov PHPStan Type Coverage Latest Version License PHP 8.4+ Symfony 8.0+ Doctrine ORM 3.5+ Total Downloads GitHub Stars Maintained

A modern, high-performance translation bundle for Symfony that stores entity translations in the same table as the source entity - no expensive joins, no complex relations.

πŸš€ Why This Bundle?

This bundle solves: Symfony Doctrine translation, entity localization, multilingual entities, Doctrine translatable, Symfony translation bundle, database translations, entity translations

❌ Traditional Translation Problems:

  • Multiple tables with complex joins
  • Performance overhead on translated entities
  • Complex queries for simple translations
  • Schema changes required for each new translation

βœ… Our Solution:

  • Single table for all translations
  • No performance penalty - same query speed as non-translated entities
  • Simple implementation - just add interface and trait
  • Zero schema changes when adding new languages

🎯 Key Features

  • 🏷️ Same-table storage - Translations stored with source entity (no joins needed)
  • ⚑ Blazing fast - No performance overhead on translated entities
  • πŸ”„ Auto-population - Automatic relation translation handling
  • 🎯 Inherited entity support - Works with complex entity hierarchies
  • πŸ›‘οΈ Type-safe - Full PHP 8.4 type declarations throughout
  • πŸ§ͺ 100% tested - Comprehensive test suite with full coverage
  • πŸ€– AI-ready - Includes AI skills for Claude Code and other assistants

πŸ—οΈ About This Version

This is a complete refactoring based on PHP 8.4, Symfony 8.0, and Doctrine ORM 3.5 of the fork from umanit/translation-bundle, implemented with modern development practices and featuring 100% code coverage with comprehensive test suites.

⚠️ Limitations

  • SharedAmongstTranslations is not available on association collections (OneToMany, ManyToMany). One collection shared between locale variants makes the owning side of the relation ambiguous, so the handlers reject it with a RuntimeException. Share the related entity's scalar columns instead. Associations themselves β€” including ManyToMany in both directions β€” are translated normally.
  • Entities with single-column unique: true constraints will trigger a validation error at cache:warmup -- use composite constraints (field + locale) instead. See UPGRADING.md for the pattern.
  • Requires PHP 8.4+, Symfony 8.0+ and Doctrine ORM 3.5+ (see legacy versions for older support)

πŸ“¦ Installation

composer require tmi/translation-bundle

Register the bundle to your config/bundles.php.

return [
// ...
Tmi\TranslationBundle\TmiTranslationBundle::class => ['all' => true],
];

βš™οΈ Configuration

Configure your available locales in framework.yaml and, optionally, additional bundle settings:

# config/packages/framework.yaml
framework:
    enabled_locales: ['en_US', 'de_DE', 'it_IT']

# config/packages/tmi_translation.yaml
tmi_translation:
    # default_locale: 'en_US'             # Optional: uses framework.default_locale if not set
    # disabled_firewalls: ['main']        # Optional: disable filter for specific firewalls
    # enable_logging: false               # Optional: enable PSR-3 debug logging
    # copy_source: false                  # Optional: clone source content (true = v1.x behavior)
    # strict_orphan_check: ~              # Optional: throw on orphaned translations. null = auto (on when kernel.debug)
    # unique_locale_variants: false       # Optional: make the (tuuid, locale) index a UNIQUE constraint

Doctrine DBAL Custom Type - TuuidType

To use the TuuidType in your Symfony project, you must register it in your Doctrine configuration:

# config/packages/doctrine.yaml
doctrine:
    dbal:
        types:
            tuuid: Tmi\TranslationBundle\Doctrine\Type\TuuidType

This ensures that Doctrine recognizes the tuuid type and avoids errors like:

Unknown column type "tuuid" requested. Any Doctrine type that you use has to be registered with \Doctrine\DBAL\Types\Type::addType(). 

πŸš€ Quick Start

Make your entity translatable

Implement Tmi\TranslationBundle\Doctrine\Model\TranslatableInterface and use the trait Tmi\TranslationBundle\Doctrine\Model\TranslatableTrait on an entity you want to make translatable.

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Tmi\TranslationBundle\Doctrine\Model\TranslatableInterface;
use Tmi\TranslationBundle\Doctrine\Model\TranslatableTrait;

#[ORM\Entity]
class Product implements TranslatableInterface
{
    use TranslatableTrait;
    
    #[ORM\Column]
    private string $name;
    
    // ... your other fields
}

Translate your entity

Use the service tmi_translation.translation.entity_translator (or type-hint Tmi\TranslationBundle\Translation\EntityTranslatorInterface) to translate a source entity to a target language.

// Translate only (caller must persist)
$translatedEntity = $entityTranslator->translate($entity, 'de_DE');

// Translate and persist in one call (v2.1)
$translatedEntity = $entityTranslator->translateAndPersist($entity, 'de_DE');

// Find existing translation or create + persist a new one (v2.1)
$translatedEntity = $entityTranslator->getOrTranslate($entity, 'de_DE');

Every attribute of the source entity will be cloned into a new entity, unless specified otherwise with the EmptyOnTranslate attribute. Generated IDs (properties with #[ORM\Id] + #[ORM\GeneratedValue]) are automatically reset to null on cloned translations.

πŸ”§ Advanced Usage

Usually, you don't wan't to get all fields of your entity to be cloned. Some should be shared throughout all translations, others should be emptied in a new translation. Two special attributes are provided in order to solve this.

SharedAmongstTranslations

Using this attribute will make the value of your field identical throughout all translations: if you update this field in any translation, all the others will be synchronized. If the attribute is a relation to a translatable entity, it will associate the correct translation to each language.

Note: this attribute cannot be used on association collections (OneToMany, ManyToMany) β€” the handlers throw a RuntimeException. Share the related entity's own columns instead.

#[ORM\ManyToOne(targetEntity: Media::class)]
#[SharedAmongstTranslations]
private Media $video; // Shared across all translations

⚠️ Ordering caveat. Shared values propagate to translations created after the value is set. If you set a shared field in one locale and translate the entity to other locales later, those earlier siblings keep their stale value. To back-fill existing data, run:

php bin/console tmi:translation:sync-shared          # propagate shared columns
php bin/console tmi:translation:sync-shared --dry-run # preview without writing

The command propagates #[SharedAmongstTranslations] column values from the default-locale row to every sibling. Shared associations are out of scope (and unsupported).

Embedded fields are covered in all three places sharing can be declared: on the entity property (the whole embeddable is copied), on the embeddable class (every inner property except those overridden with #[EmptyOnTranslate]), or on an individual inner property. This mirrors how EmbeddedHandler resolves sharing at translate time.

A readonly shared property cannot be written after hydration. The command reports such rows instead of crashing, syncs everything else, and exits non-zero β€” correct those values manually or at the database level.

EmptyOnTranslate

This attribute will empty the field when creating a new translation. ATTENTION: The field has to be nullable or instance of Doctrine\Common\Collections\Collection!

#[ORM\ManyToOne(targetEntity: Owner::class, cascade: ['persist'], inversedBy: 'product')]
#[ORM\JoinColumn(name: 'owner_id', referencedColumnName: 'id', nullable: true)]
#[EmptyOnTranslate]
private Owner|null $owner = null

#[ORM\Column(type: 'string', nullable: true)]
#[EmptyOnTranslate]
private string|null $title = null;

Translate event

You can alter the entities to translate or translated, before and after translation using the Tmi\TranslationBundle\Event\TranslateEvent

  • TranslateEvent::PRE_TRANSLATE called before starting to translate the properties. The new translation is just instanciated with the right oid and locale
  • TranslateEvent::POST_TRANSLATE called after saving the translation

Filtering your contents

To fetch your contents out of your database in the current locale, you'd usually do something like $repository->findByLocale($request->getLocale()).

Alternatively, you can use the provided filter that will automatically filter any Translatable entity by the current locale, every time you query the ORM. This way, you can simply do $repository->findAll() instead of the previous example.

Add this to your config.yml file:

# Doctrine Configuration
doctrine:
  orm:
    filters:
      # ...
      tmi_translation_locale_filter:
        class:   'Tmi\TranslationBundle\Doctrine\Filter\LocaleFilter'
        enabled: true

The filter locale is set on every kernel.request, including sub-requests (fragments rendered with render(controller(...)), ESI, forwards), so a fragment queries in its own locale. On kernel.finish_request the parent request's locale is re-applied, mirroring Symfony's LocaleAwareListener β€” the shared EntityManager filter never keeps a fragment's locale for the rest of the parent request.

(Optional) Disable the filter for a specific firewall

Usually you'll need to administrate your contents. For doing so, you can disable the filter by configuring the disabled_firewalls option in your configuration:

# config/packages/tmi_translation.yaml
tmi_translation:
  disabled_firewalls: ['main']  # Disable filter for 'main' firewall

Quick Fix for unique fields

If you need a translatable slug (or UUID), adjust your database schema to make the slug unique per locale, instead of globally:

#[ORM\Entity]
#[ORM\Table(name: 'product')]
#[ORM\UniqueConstraint(
    name: "uniq_slug_locale",
    columns: ["slug_value", "locale"]
)]
class Product
{
    #[ORM\Column(length: 255)]
    private ?string $slug = null;

    #[ORM\Column(length: 5)]
    private string $locale;
}

Querying Locale Variants

Always use these helpers for cross-locale lookups. Hand-rolled WHERE tuuid = ... queries get the tmi_translation_locale_filter wrong and silently return only the current-locale row.

The quickest way is to extend the ready-made TranslatableEntityRepository base class:

use Tmi\TranslationBundle\Doctrine\Repository\TranslatableEntityRepository;

/** @extends TranslatableEntityRepository<Product> */
class ProductRepository extends TranslatableEntityRepository
{
}

If your repository must extend something else, use TranslatableRepositoryTrait directly (v2.1):

use Doctrine\ORM\EntityRepository;
use Tmi\TranslationBundle\Doctrine\Repository\TranslatableRepositoryTrait;

class ProductRepository extends EntityRepository
{
    use TranslatableRepositoryTrait;
}
// All locale variants for a single Tuuid, keyed by locale
$variants = $repository->findAllLocaleVariants($product->getTuuid());
// ['en_US' => Product, 'de_DE' => Product, ...]

// Batch lookup for multiple Tuuids
$batch = $repository->findAllLocaleVariantsBatch([$tuuid1, $tuuid2]);
// ['<tuuid1>' => ['en_US' => Product, ...], '<tuuid2>' => [...]]

Both methods temporarily disable the tmi_translation_locale_filter (if enabled) to query across all locales.

The tuuid column is automatically indexed: the bundle injects a composite (tuuid, locale) index into every translatable entity at mapping time, so locale-variant lookups never hit an unindexed scan. Set unique_locale_variants: true to promote it to a UNIQUE constraint β€” do this only once existing data is free of duplicate locale rows (see tmi:translation:doctor).

Translation Linkage & Diagnostics

Every locale variant of an entity shares one Tuuid. Translations created through EntityTranslator::translate() inherit it automatically. If application code instead does new Entity() + setLocale('de_DE') and persists it without a shared Tuuid, the result is a standalone entity linked to no other locale β€” a silent data bug.

The bundle guards against this:

  • On persist β€” an entity persisted in a non-default locale without a shared Tuuid is flagged. strict_orphan_check controls the reaction: true throws, false logs a warning, null (default) is auto β€” throws when kernel.debug is on, warns otherwise.

  • tmi:translation:doctor β€” scans every translatable table and reports standalone translations, incomplete translations (fewer locale rows than configured locales) and duplicate (tuuid, locale) pairs. Exits non-zero on findings, so it works as a post-migration / CI integrity gate:

    php bin/console tmi:translation:doctor
    
  • tmi:translation:sync-shared β€” see below.

πŸ“Š Performance Comparison

Operation Traditional Bundles TMI Translation Bundle
Fetch translated entity 3-5 SQL queries 1 SQL query
Schema complexity Multiple tables Single table
Join operations Required None
Cache efficiency Low High

πŸ€– AI-Assisted Development

This bundle includes AI skills that help you implement translations correctly. These skills work with Claude Code and other AI coding assistants.

Available Skills

Skill Purpose When to Use
Entity Translation Setup Guides you through making any Doctrine entity translatable "Make my Product entity translatable"
Translation Debugger Diagnoses and fixes translation configuration issues "Translation not working", "Why isn't my entity translating?"
Custom Handler Creator Helps create custom handlers for specialized field types "Create a handler for encrypted fields"

Using with Claude Code

If you're using Claude Code, the skills are automatically available when working in this project. Simply describe what you need:

# Make an entity translatable
"Make my Article entity translatable with shared author and translated title/content"

# Debug translation issues
"Translation is not working for my Product entity"

# Create custom handlers
"Create a custom handler for my Money value object"

Claude Code will automatically invoke the appropriate skill and guide you through the process.

Using with Other AI Assistants

The skills are defined in .agents/skills/ and follow a standard markdown format. Point your AI assistant to:

For comprehensive documentation optimized for AI assistants, see:

  • llms.txt - Quick reference with all important links
  • llms.md - Detailed guide with handler chain decision tree and troubleshooting

πŸ“– Upgrading

See UPGRADING.md for migration guides between major versions.

🀝 Contributing

We welcome contributions!

πŸ“„ License

This bundle is licensed under the MIT License.

πŸ™ Acknowledgments

Based on the original work by umanit/translation-bundle, now completely modernized for current PHP and Symfony ecosystems.


⭐ If this bundle helps you, please give it a star on GitHub!