All posts
SymfonyDatabases October 24, 2026 · 3 min read

Content translations like WordPress, built with Symfony

In WordPress with WPML and in Joomla, every translation is a separate record with its own URL, SEO and date. I wanted the same for a Symfony portfolio, without extra bundles.

The problem

The site has two languages, and a third may come later. Project pages and blog posts are written separately for each language: not a word-for-word translation but an adapted text with its own title, URL and search description.

Still, the site has to know that “Переклади контенту” and “Content translations” are the same article. Otherwise the language switcher has nowhere to go, and Google gets no hreflang.

Why not JSON fields

A popular option is to keep every language in one row: title_uk, title_en or a JSON field. Fine for product names, bad for long content:

  • You cannot publish the Ukrainian version while the English one is still a draft.
  • Slug, SEO and publish date differ per language, so the number of fields doubles.
  • Adding a third language means migrating every table.

Data schema

Each language is its own record. Records of one article are linked through a translation_group table. The pair “group + locale” is unique, so one article can never have two English versions.

Translation group #12 with a shared cover, tags and order and two records: post · uk at /uk/blog/pereklady-kontentu and post · en at /en/blog/content-translations, both published
A translation group and its two records
src/Entity/Post.phpPHP
#[ORM\Entity(repositoryClass: PostRepository::class)]
#[ORM\UniqueConstraint(columns: ['translation_group_id', 'locale'])]
class Post
{
    #[ORM\ManyToOne(inversedBy: 'posts')]
    #[ORM\JoinColumn(nullable: false)]
    private TranslationGroup $translationGroup;

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

    #[ORM\Column(length: 160)]
    private string $title;

    #[ORM\Column(length: 160)]
    private string $slug;
}

Finding a translation

The language switcher and hreflang tags read the other records of the same group. It is a single query, with no joins over per-language tables:

src/Repository/PostRepository.phpPHP
public function findTranslation(Post $post, string $locale): ?Post
{
    return $this->findOneBy([
        'translationGroup' => $post->getTranslationGroup(),
        'locale' => $locale,
        'status' => PostStatus::Published,
    ]);
}

What all languages share

Some data does not depend on language: the cover, tags, order on the site, the client link. I keep it on the group, not on the record. Change a tag in the Ukrainian version and it changes in the English one too.

Tags have a name per language but one address: /uk/blog?tag=symfony and /en/blog?tag=symfony show the same articles, each in its own language.

The result

  • Each language goes live when it is ready, with its own slug and SEO.
  • A third language is a settings entry, not a migration.
  • The admin list shows straight away which translations are missing.

The same schema powers projects, services and experience. If you are building a multilingual site on Symfony or Laravel and want the same, get in touch and I’ll share the details.

Topics: SymfonyDatabases
Віталій
Get in touch

Need a developer for your project?

Tell me about the task and I’ll tell you how to build it and how long it will take.

Email me sliusardev@gmail.com

Write to me

Briefly describe the task — I will reply by email, usually within a day.