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.
#[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:
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.