DEV Community

Cover image for Building multilingual runtime collections in a Filament plugin
Serhii
Serhii

Posted on

Building multilingual runtime collections in a Filament plugin

Building multilingual runtime collections in a Filament plugin

I just released v1.2.0 of Filament Studio, a Filament v5 plugin I am building for runtime collections, custom fields, dashboards, filters, and APIs.

The short version: this release adds multilingual support.

The longer version: adding multilingual support to a runtime data model is not the same as adding a translations JSON column and moving on.

Filament Studio is built for admin panels where the data model changes after the app is already running. A user can create a collection, add fields, manage records, build dashboards, and expose API endpoints without writing a new migration and Filament resource for each new content type.

That flexibility creates a specific problem for translations.

If the fields are defined at runtime, translation behavior also has to be defined at runtime.

The problem I wanted to solve

In real admin systems, multilingual content is rarely all-or-nothing.

A collection might support English and French.

Inside that collection:

  • title should be translated
  • description should be translated
  • price should stay shared
  • published_at should stay shared
  • is_featured should stay shared

That means translation behavior belongs at the field level, not only at the record or collection level.

I also wanted the feature to work across the whole plugin:

  • generated Filament forms
  • EAV storage
  • query builder reads and writes
  • REST API responses
  • OpenAPI documentation
  • fallback behavior
  • version snapshots

Otherwise it would feel like a partial feature.

Global and collection config

Multilingual support is opt-in.

// config/filament-studio.php
'locales' => [
    'enabled' => true,
    'available' => ['en', 'fr', 'de'],
    'default' => 'en',
],
Enter fullscreen mode Exit fullscreen mode

You can also enable it through the environment:

STUDIO_LOCALES_ENABLED=true
Enter fullscreen mode Exit fullscreen mode

Each collection can then define its own supported locales and default locale:

$collection->update([
    'supported_locales' => ['en', 'fr'],
    'default_locale' => 'en',
]);
Enter fullscreen mode Exit fullscreen mode

If a collection does not define supported locales, it uses the global list.

Per-field translation

Each field now has a translatable flag.

That lets a collection mix localized and shared data:

title        translatable
description  translatable
price        shared
published_at shared
is_featured  shared
Enter fullscreen mode Exit fullscreen mode

This is the part of the design I care about most. In multilingual admin work, not every value is content. Some values are facts, flags, prices, dates, or relationships. Treating all of them as translatable creates noise and sometimes bugs.

Storage model

Filament Studio uses an EAV model because fields are created at runtime.

For v1.2.0, the studio_values table now includes a locale column.

For translatable fields, values are stored per locale.

For shared fields, one value is used regardless of active locale.

The uniqueness boundary is:

record_id + field_id + locale
Enter fullscreen mode Exit fullscreen mode

That allows a record to have different localized values for the same runtime field without creating new database columns for every translated field.

Locale resolution

I added a dedicated LocaleResolver service so the rules live in one place.

The active locale is resolved in this order:

  1. ?locale= query parameter
  2. X-Locale header
  3. session
  4. collection default
  5. global default

That gives API consumers a direct way to request a locale, while the admin panel can remember the selected locale in the session.

Query builder API

The EavQueryBuilder now supports a locale() method:

$data = EavQueryBuilder::for($collection)
    ->locale('fr')
    ->getRecordData($record);
Enter fullscreen mode Exit fullscreen mode

Create and update operations can also target a locale:

$record = EavQueryBuilder::for($collection)
    ->locale('fr')
    ->create([
        'title' => 'Mon Titre',
    ]);
Enter fullscreen mode Exit fullscreen mode
EavQueryBuilder::for($collection)
    ->locale('fr')
    ->update($record->id, [
        'title' => 'Titre mis a jour',
    ]);
Enter fullscreen mode Exit fullscreen mode

There is also getAllLocaleData() for retrieving every translation at once:

$allData = EavQueryBuilder::for($collection)
    ->getAllLocaleData($record);
Enter fullscreen mode Exit fullscreen mode

Example result:

[
    'title' => [
        'en' => 'My Title',
        'fr' => 'Mon Titre',
    ],
    'price' => 29.99,
]
Enter fullscreen mode Exit fullscreen mode

Translatable fields return locale maps. Shared fields stay plain.

Fallback metadata

I did not want fallback behavior to be silent.

If an API client asks for French and a field falls back to English, the client should be able to detect that.

So getRecordDataWithMeta() returns fallback information:

$result = EavQueryBuilder::for($collection)
    ->locale('fr')
    ->getRecordDataWithMeta($record);
Enter fullscreen mode Exit fullscreen mode

Example:

[
    'data' => [
        'title' => 'Mon Titre',
        'slug' => 'my-slug',
    ],
    'fallbacks' => ['slug'],
]
Enter fullscreen mode Exit fullscreen mode

That tells the caller that slug came from the default locale.

REST API support

API endpoints now accept locale selection through query params:

curl -H "X-Api-Key: your-key" \
     "https://your-app.com/api/studio/posts?locale=fr"
Enter fullscreen mode Exit fullscreen mode

or through headers:

curl -H "X-Api-Key: your-key" \
     -H "X-Locale: fr" \
     "https://your-app.com/api/studio/posts"
Enter fullscreen mode Exit fullscreen mode

Responses include metadata:

{
  "data": {
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "data": {
      "title": "Mon Titre",
      "slug": "my-slug",
      "price": 29.99
    }
  },
  "_meta": {
    "locale": "fr",
    "fallbacks": ["slug"]
  }
}
Enter fullscreen mode Exit fullscreen mode

For single-record reads, all_locales=true returns every locale:

curl -H "X-Api-Key: your-key" \
     "https://your-app.com/api/studio/posts/550e8400?all_locales=true"
Enter fullscreen mode Exit fullscreen mode

OpenAPI docs

When multilingual support is enabled, the generated OpenAPI docs include:

  • locale query parameter
  • X-Locale header parameter
  • all_locales query parameter
  • _meta.locale
  • _meta.fallbacks

This felt worth doing because API features are easy to misunderstand when the docs do not show the request and response shape.

Admin UI and versioning

In the Filament admin, multilingual collections now get a locale switcher on record pages.

Editors can switch locale and edit only the fields that should change for that locale.

Version snapshots also store all locale values for translatable fields:

{
  "title": {
    "en": "My Title",
    "fr": "Mon Titre"
  },
  "slug": {
    "en": "my-slug"
  },
  "price": 29.99
}
Enter fullscreen mode Exit fullscreen mode

That way restoring a version does not only restore whichever locale happened to be active at the time.

Why I am sharing this

This was one of those updates that made the package feel more practical.

The first version of a dynamic admin builder can look good with just runtime fields and generated forms.

But real projects keep asking for the less glamorous pieces:

  • permissions
  • APIs
  • versioning
  • tenancy
  • filtering
  • multilingual content

That is the direction I am trying to push Filament Studio.

GitHub: https://github.com/flexpik/filament-studio

Packagist: https://packagist.org/packages/flexpik/filament-studio

If you have built multilingual admin panels in Laravel, I would like feedback on the model:

  • should translation behavior live per field?
  • is fallback metadata useful in practice?
  • would all_locales=true work for your API use cases?
  • what edge cases am I missing?

Top comments (0)