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.
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.