Abdulkader Safi

The storage shape for custom fields was already in the codebase, one column over

A page type declares its own fields, and the plugin never learns their names. That leaves one question: where the values go, and what happens to the translated ones. I designed a shape, then noticed the SEO column had been storing the same thing correctly since the first release.

4 min read

Share
A page settings screen with a section of custom fields, the translated one sitting under English and Arabic tabs.

Page types let a developer declare whatever fields the site needs. A service carries a duration and three pricing tiers, a product carries a SKU and its variations, and the package knows none of those names.

public static function fields(): array
{
    return [
        Textarea::make('excerpt')->rows(2),
        FileUpload::make('card_image')->image(),
        TextInput::make('starting_price')->numeric(),
    ];
}

Whatever comes back from that method is what the panel renders and what a view reads back. It is a plain Filament schema, so every field type the framework has works, and the plugin contributes nothing but a section to put them in. Same trick as a block's schema(), which is why the whole control system comes free.

The question that is left

Values need somewhere to live. One JSON column, data, answers most of it. The interesting half is translation, because an excerpt is per language and a price is not.

When I wrote the spec I picked the shape a block already uses for its attributes:

{ "excerpt": { "en": "...", "ar": "..." }, "price": 2500 }

Key first, locale second. It matches Renderer::localise() and it is the shape every block tree in the database already carries.

Why I changed it before writing a line

Building the form found the problem. The panel needs one tab per locale, each holding that locale's fields. With the key first, a field named excerpt inside the Arabic tab has to be rendered at the path data.excerpt.ar, which means renaming components at build time or duplicating each field with a different name.

Then I opened the settings screen I already had. The SEO fields, shipped since the first release, are written like this:

TextInput::make("seo.{$code}.meta_title")

Locale first. seo.en.meta_title, seo.ar.meta_title. The same problem, solved eighteen releases ago, in the file I was editing.

So data copies it. Untranslated values live at data.{key}, translated ones at data.{locale}.{key}, and the form becomes a group with a state path per tab rather than a field-renaming exercise:

Group::make($type::fields())->statePath("data.{$code}")

Two shapes now exist in one database, key-first inside a block tree and locale-first in the two settings columns. That is not ideal, and it is still better than a third one. Blocks keep theirs because a block's attributes are one node in a tree that gets walked, collapsed and rendered as a unit; a settings column is a flat map read a key at a time.

One reader, one rule

Which shape a key uses is decided by the type, not guessed:

public function data(string $key, ?string $locale = null, mixed $default = null): mixed
{
    $type = $this->pageType();

    if ($type === null || ! in_array($key, $type::translatable(), true)) {
        return data_get($this->data, $key) ?: $default;
    }

    // data.{locale}.{key}, then the first configured locale, then the default.
}

Guessing would mean treating any two-letter key as a locale, which breaks the first time somebody names a field en or ships a site in a language whose code collides with a field name.

The fallback matters more than it looks. A card with no Arabic excerpt shows the English one rather than a gap, which is the same convention the block renderer and the menu labels already use: a half-translated site should read as untranslated, not as broken.

Fields that are not drafted

One decision I flagged rather than hid. Title, slugs, SEO and structured data all save immediately on this screen, outside the draft and publish cycle that governs block content. Custom properties join them.

So editing a service's price changes the live page at once, while its sections still need publishing. The alternative is splitting data into draft and published copies, which makes one section of the settings screen behave differently from every other section on it. That is a worse surprise than the one it prevents.

Where it stands

Shipped in v0.5.0. A type declares its fields and which are translated, $page->data('excerpt', $locale) reads either shape, and the migration adds two columns without touching a single existing row.

The lesson I keep relearning: before designing a shape, check what the same codebase already does with the same problem. The answer had been in the file I was editing the whole time.

Last updated 13 Sep 2026 · filed under Laravel, filamentphp, plugin, tools, web application, ux

Every entry on this project

28 build notes, in order

Including the ones where nothing worked. You are on part 19.

Read the series →

FAQ

Frequently asked questions

Should translated values be stored as key then locale, or locale then key?

Pick whichever the surrounding code already uses, and only then argue about which is better. Locale-first paths, such as data.ar.excerpt, are easier to build forms for, because a tab per language becomes a group with a state path and no field needs renaming. Key-first, such as excerpt.ar, is easier to walk when a whole structure is collapsed to one language at render time, which is why tree-shaped content often uses it. The real cost is having three shapes in one database, so consistency with an existing column beats a marginal win.

How should code know whether a stored value is a locale map or a plain value?

From a declaration, never from the shape of the data. Ask the type or the block which of its keys are translated, and read accordingly. Inferring it by checking whether the value is an array with two-letter keys works until somebody stores a field named after a language code, or a repeater whose rows happen to look like that. A declared list is also documentation: it tells the next developer which fields need a translator's attention.

What should happen when a translation is missing?

Fall back to the default language for text, and return nothing for anything that is a link. A missing sentence shown in another language reads as untranslated, which is honest and usable. A URL borrowed from another locale is a broken link that sends a visitor to the wrong version of the site, so it should be absent instead. Apply the same rule everywhere in the system, because a fallback that works in the body and not in the navigation is the kind of inconsistency people report as two separate bugs.

Should extra fields on a page follow the draft and publish cycle?

Follow whatever the fields next to them do. If the title, the slug and the metadata on the same screen save immediately, one section that quietly requires publishing is a trap, and the person who edits a price and sees no change on the site will assume the save failed. Drafting is worth extending to those fields only if you extend it to the whole screen, which is a bigger change than it looks: it needs a second copy of the data, a diff view and a publish action that covers both.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from Filament Atelier

All 28 entries →
  • A version number changing from 0.5.0 to 1.0.0 beside a list of deferred items.
    Filament Atelier

    · part 28 of 28

    Tagging 1.0.0 with four features missing, on purpose

    The gate list had thirteen items. Six went in, seven did not, and the tag went out anyway. What the number promises is that the API stops moving, not that the feature list is finished, and conflating those two is how packages sit at 0.x for three years.

  • The same block of JavaScript appearing in three different Blade layout files.
    Filament Atelier

    · part 26 of 28

    A script in three layouts is a contract nobody signed

    The editor's preview needed a few lines of JavaScript in the page it renders. I put them in the shipped layout, then copied them into two more. Anyone writing their own layout had to copy them too, and missing them broke half the editor with no error at all.