You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Built to the next-gen general rules: CI with test and coverage badges, full docs for people and agents, Laravel Boost guideline and skill, Javaabu codestyle, PHP 8.4 / Laravel 12+ (Laravel packages) or TypeScript (JS packages), Dhivehi and RTL, Blade and Inertia React for views, overridable controllers and policies with javaabu/permissions.
A. A shared, polymorphic, typed-columns meta table: value_string, value_text, value_int, value_float, value_datetime, value_json, plus (metable_type, key, value_*, metable_id) indexes and a unique (metable_type, metable_id, key, locale) index.
B. A JSON column on the model's table, with and without generated, indexed columns.
C. A per-model typed table.
My expectation is A, with C supported as an option through the same schema ($meta_table on the model). B needs a migration on the model's table for every indexed key, which rules out definitions that change at runtime and custom types.
HasMeta trait.
meta() is a MorphMany relation, so Model::with('meta') eager loads.
Methods: getMeta, setMeta, hasMeta, removeMeta, syncMeta and getAllMeta.
A meta attribute mutator makes fill(['meta' => [...]]) and create() work. Mass assignment only takes defined fields, and it saves on saved.
The relation's collection reads by key ($post->meta['subtitle']), so stencil binds meta.subtitle fields without glue.
Definitions (Define meta fields with stencil #3).MetaFields::for(Post::class, [TextInput::make('subtitle'), Checkbox::make('featured')], group: null):
Each stencil field's meta type is inferred from the field. For example, Checkbox is boolean, TextInput::integer() is integer, DatePicker is date and Textarea is text. ->metaType('…') overrides the inferred type.
group covers post types: the model's metaGroup(), such as $this->type, adds the group's fields to the defaults.
MetaFields::schema($post) returns the stencil fields named meta.*, ready to drop into any Form. MetaFields::rules() returns the validation rules.
Types (Extensible meta field types #5). A MetaType class defines the storage column, casting both ways, the default stencil field, the stencil fields it's inferred from, and an optional custom where. Built-in types: string, text, integer, float, boolean, date, datetime, time, json. Custom types are registered with Meta::type() or in config.
whereMeta, orWhereMeta, whereMetaIn, orWhereMetaIn, whereMetaNotIn, whereHasMeta, whereDoesntHaveMeta and orderByMeta.
Each scope uses the defined type's column, or infers the column from the value for keys that aren't defined.
Tests check EXPLAIN on both databases to confirm the scopes use the indexes.
Translatable (Optional javaabu/translatable support #9). Rows have a locale column, and non-translatable values use ''. Translatable fields store one row per locale and read with a fallback. Translatable is feature-detected through a single Support\Translations class, as in settings.
Order of work
#1 → benchmark and storage (#2) → types (#5) → definitions (#3) → scopes (#4) → #9 → #8 → docs, llms.txt and Boost (#6, #7). CI runs the PHPUnit matrix on SQLite plus a MySQL leg, along with Pint and a leg without translatable.
Open questions
Stencil's translatable fields expand only for model attributes that Translatable::supports() knows. They also name nested inputs meta.subtitle_dv, which PHP mangles. Translatable meta fields in forms may need a hook in stencil. I'll confirm this with a test and open a cross-package issue if it's needed.
The package has no views or controllers, since forms render through stencil. Unless post-types needs one, there's no meta management UI.
Contract: definition and query APIs (pushed to main, for javaabu/post-types)
Install it with "javaabu/meta": "^1.0@dev" and a VCS repository https://github.com/Javaabu/meta. The migration runs on its own; turn that off with meta.run_migrations.
Model
useJavaabu\Meta\Concerns\HasMeta;
class Post extends Model
{
use HasMeta;
// Optional: fields for some records only (a post type).publicfunctionmetaGroup(): ?string
{
return$this->type;
}
// Optional: keep this model's meta in its own table, created with MetaTable::columns($table, 'post_meta').protectedstring$meta_table = 'post_meta';
}
Definitions (Javaabu\Meta\Facades\MetaFields)
MetaFields::for(Post::class, [
TextInput::make('subtitle')->maxLength(255)->translatable(),
Checkbox::make('featured'),
TextInput::make('views')->integer()->metaType('integer'),
'rating' => 'float', // key => type, field from the type
]);
MetaFields::for(Post::class, [DatePicker::make('starts_on')], group: 'event'); // only where metaGroup() === 'event'MetaFields::for(Post::class, fn () => [...]); // a closure, resolved when the fields are needed
DatePicker / DateTimePicker / TimePicker → date / datetime / time
Textarea / Wysiwyg → text
Select::multiple() / CheckboxList → json
everything else → string (255 characters at most)
Relationship and media fields are refused.
Forms
$form = MetaForm::for($post)->action(route('posts.meta.update', $post)); // a stencil Form: {{ $form }} or an Inertia prop$form->embedded(); // only the fields, inside the post's own <form>$form->rules(); // field rules plus type rulesMetaForm::for($post)->save(); // validates the request and saves; a new record gets its meta when it's saved$post->fillMeta($validated); // the same from your own validated data: defined keys only, and subtitle_en/subtitle_dv inputs
The input names are the meta keys (subtitle, tags[], and subtitle_en/subtitle_dv for translatable fields). When you embed the form in the post's own form, keep meta keys distinct from the post's attributes.
Reading and writing
$post->getMeta('subtitle', $default = null, ?string $locale = null); // current locale → default locale → value without a locale$post->getMetaTranslation('subtitle', 'dv', fallback: false);
$post->getMetaTranslations('subtitle'); // ['en' => …, 'dv' => …]$post->hasMeta('subtitle'); $post->getAllMeta();
$post->setMeta('subtitle', 'ސަބް'); $post->setMeta([...]); // writes straight away; null removes; locale maps for translatable fields$post->setMeta('subtitle', 'ސަބް', locale: 'dv');
$post->removeMeta(['a', 'b'], ?string $locale = null); $post->syncMeta([...]);
Post::create(['title' => …, 'meta' => ['subtitle' => …]]); // defined keys only, written on save$post->meta; // MetaCollection; $post->meta['subtitle'] and data_get($post, 'meta.subtitle') read values
Query scopes
Post::whereMeta('featured', true)->whereMeta('views', '>', 100)->orWhereMeta('color', 'red');
Post::whereMeta('subtitle', null); // records without the key; '!=' null means with itPost::whereMetaIn('color', ['red', 'blue'])->orWhereMetaIn(...)->whereMetaNotIn(...)->orWhereMetaNotIn(...);
Post::whereHasMeta(['subtitle', 'rating'])->orWhereHasMeta(...)->whereDoesntHaveMeta(...)->orWhereDoesntHaveMeta(...);
Post::orderByMeta('views', 'desc')->orderByMetaDesc('rating'); // undefined keys need a type: orderByMeta('position', 'asc', 'integer')Post::with('meta')->get(); Post::withMeta(['subtitle', 'featured'])->get(); // no N+1
!= and NotIn only match records that have the key.
Translatable keys match in any locale and sort in the current locale, falling back to the default.
Conditions compile to id IN (select metable_id from meta where metable_type = ? and key = ? and value_* …), which is index-only. Sorts are a left join on the unique record index. Numbers are on HasMeta and storage design #2.
Custom types
Extend Javaabu\Meta\Types\MetaType:
name() and column() (a MetaColumn: String, Text, Integer, Float, DateTime or Json)
serialize() / unserialize()
field($key)
matches(Field), accepts($value) and rules()
optional where() / whereIn()
Register the type with MetaFields::type(ColorType::class) or config('meta.types').
javaabu/meta v1.0 is on main, and CI is green on every leg:
PHP 8.4/8.5 × Laravel 12/13 × lowest/stable on SQLite, with coverage from 8.4/13/stable
MySQL 8.0 and 8.4
a leg without the optional javaabu/translatable
Pint
a production-dependency check (no javaabu/helpers, no settings v1)
The suite has 118 PHPUnit tests. Every next-gen issue is closed except #10, which needs a human.
What was built
Storage (HasMeta and storage design #2). A shared table with typed value columns (value_string, text, int, float, datetime and json), (metable_type, key, value_*, metable_id) indexes and a unique record index. I chose it from benchmarks on MySQL 9.7 and SQLite 3.45 with 100k records and 800k meta rows; the numbers are on HasMeta and storage design #2 and in docs/advanced-usage/storage-and-performance.md. Models can opt into a table of their own with $meta_table and MetaTable::columns(), and UUID or ULID keys are supported.
HasMeta.
The meta relation's collection reads values by key, so data_get($post, 'meta.subtitle') works.
Methods: getMeta, setMeta (one upsert), removeMeta, syncMeta, getAllMeta and fillMeta.
Mass assignment with 'meta' => [...] takes defined keys only.
Meta set on an unsaved record is written when it's saved. Meta is deleted with the record, and soft deletes keep it.
Definitions (Define meta fields with stencil #3).MetaFields::for(Model::class, [stencil fields], group:) registers fields, and records pick a group with metaGroup(). Definitions can be closures or key => type pairs, and parent-class definitions are inherited.
Forms (Define meta fields with stencil #3).MetaForm::for($record) is a stencil form for Blade and React. It validates with field and type rules, saves the meta, and can be embedded in the record's own form.
Types (Extensible meta field types #5). The built-in types are string, text, integer, float, boolean, date, datetime, time and json. The type is inferred from the stencil field or set with ->metaType(). Custom MetaTypes choose their column, casting, queries, field and rules.
Values are stored one row per locale and read with fallback.
Locale maps and {key}_{locale} inputs are accepted.
Queries match in any locale, and sorting uses the current locale.
Forms have one input per locale, through a never-saved JSON-translatable record (settings v2's approach).
It's tested with and without the package.
Dhivehi and RTL (Dhivehi localization and RTL #8).lang/en and lang/dv, Thaana in the tests. The package has no views of its own; forms render through stencil and forms.
Contract for post-types. Posted above on this issue, and in docs/advanced-usage/for-package-authors.md.
Intentionally out of scope for v1
A meta management UI, controllers, policies and permissions. Meta fields are edited through MetaForm inside the app's own pages (or post-types'), so there are no routes or views to override.
Dhivehi review. One string, listed in docs/advanced-usage/dhivehi-and-rtl.md#for-review.
Release. Tag the release and submit it to Packagist. Once stencil, forms, manifest and translatable v3 are released, drop the VCS repositories in composer.json.
Update: meta now uses the stencil APIs from Javaabu/stencil#16 (b83e2ad). Post-types, this is the part you build on.
Meta fields in the record's own form (new):
Form::make()->model($post)->schema([
TextInput::make('title')->required(),
Section::make(__('Details'), [MetaFields::nested($post)]), // or MetaFields::nested($post, group: 'event')
]);
MetaFields::nested($record|$class, ?$group) returns a stencil Group with statePath('meta') (MetaFields::STATE_PATH). It holds fresh copies of the record's fields, including its metaGroup()'s.
Inputs are meta[subtitle], state and rule paths are meta.subtitle, and ids are meta-subtitle. Values come from data_get($post, 'meta.subtitle'), which reads $post->meta.
validate() returns ['title' => …, 'meta' => [...]]. Stencil's save()/fillModel(), create() and update() pass meta to HasMeta, which writes the defined keys when the post is saved (new posts included).
MetaFields::formFields($record|$class, ?$group) gives the bare field copies for a layout of your own.
Conditions inside take full paths: visibleWhen('meta.featured', true).
Translatable meta in that form:
The model implements Javaabu\Stencil\Contracts\ResolvesTranslatableFields. HasMeta::isTranslatableField() is the implementation, so just add implements ResolvesTranslatableFields.
It answers meta.* from the definitions, and the model's own fields through javaabu/translatable, so a translatable Post keeps its title_en/title_dv.
Inputs are meta[subtitle_en] and meta[subtitle_dv], and they save like a meta form's.
MetaFields::isTranslatable($record|$class, $key, ?$group) answers per key.
Other changes:
TextInput::integer() is now stored as integer without ->metaType('integer'), and ->metaType() still overrides.
Translatable fields are detected with Field::isDeclaredTranslatable(). The probe form is gone.
MetaForm and ->embedded() are unchanged. Stencil forms aren't Stringable any more, so render with {{ $form }} or ->toHtml(), not (string) $form.
The public API is unchanged apart from these additions; the only removal is the internal TranslatableMetaRecord::probe(). Docs: docs/basic-usage/forms.md#inside-the-records-own-form, docs/advanced-usage/for-package-authors.md, docs/advanced-usage/translatable.md#in-the-records-own-form. Tests run on SQLite and MySQL, and without translatable.
Activity
n4ff4h commentedon Nov 5, 2026
Implementation plan
Architecture
Storage (HasMeta and storage design #2). I'll benchmark three options on MySQL and SQLite with about 100k models and 800k meta rows, then record the numbers on HasMeta and storage design #2:
metatable:value_string,value_text,value_int,value_float,value_datetime,value_json, plus(metable_type, key, value_*, metable_id)indexes and a unique(metable_type, metable_id, key, locale)index.My expectation is A, with C supported as an option through the same schema (
$meta_tableon the model). B needs a migration on the model's table for every indexed key, which rules out definitions that change at runtime and custom types.HasMetatrait.meta()is aMorphManyrelation, soModel::with('meta')eager loads.getMeta,setMeta,hasMeta,removeMeta,syncMetaandgetAllMeta.metaattribute mutator makesfill(['meta' => [...]])andcreate()work. Mass assignment only takes defined fields, and it saves onsaved.$post->meta['subtitle']), so stencil bindsmeta.subtitlefields without glue.Definitions (Define meta fields with stencil #3).
MetaFields::for(Post::class, [TextInput::make('subtitle'), Checkbox::make('featured')], group: null):Checkboxis boolean,TextInput::integer()is integer,DatePickeris date andTextareais text.->metaType('…')overrides the inferred type.groupcovers post types: the model'smetaGroup(), such as$this->type, adds the group's fields to the defaults.MetaFields::schema($post)returns the stencil fields namedmeta.*, ready to drop into anyForm.MetaFields::rules()returns the validation rules.Types (Extensible meta field types #5). A
MetaTypeclass defines the storage column, casting both ways, the default stencil field, the stencil fields it's inferred from, and an optional customwhere. Built-in types: string, text, integer, float, boolean, date, datetime, time, json. Custom types are registered withMeta::type()or in config.Scopes (Query scopes #4).
whereMeta,orWhereMeta,whereMetaIn,orWhereMetaIn,whereMetaNotIn,whereHasMeta,whereDoesntHaveMetaandorderByMeta.EXPLAINon both databases to confirm the scopes use the indexes.Translatable (Optional javaabu/translatable support #9). Rows have a
localecolumn, and non-translatable values use''. Translatable fields store one row per locale and read with a fallback. Translatable is feature-detected through a singleSupport\Translationsclass, as in settings.Order of work
#1 → benchmark and storage (#2) → types (#5) → definitions (#3) → scopes (#4) → #9 → #8 → docs,
llms.txtand Boost (#6, #7). CI runs the PHPUnit matrix on SQLite plus a MySQL leg, along with Pint and a leg without translatable.Open questions
Translatable::supports()knows. They also name nested inputsmeta.subtitle_dv, which PHP mangles. Translatable meta fields in forms may need a hook in stencil. I'll confirm this with a test and open across-packageissue if it's needed.n4ff4h commentedon Nov 5, 2026
Contract: definition and query APIs (pushed to
main, for javaabu/post-types)Install it with
"javaabu/meta": "^1.0@dev"and a VCS repositoryhttps://github.com/Javaabu/meta. The migration runs on its own; turn that off withmeta.run_migrations.Model
Definitions (
Javaabu\Meta\Facades\MetaFields)fields(Model|string $model, ?string $group = null)array<string, MetaField>: the model's own fields, then the group's. A record uses itsmetaGroup()field($model, $key, $group = null)/keys(...)/groups(Post::class)?MetaField/array<int, string>/array<int, string>queryField(Post::class, $key)form(Model $record, ?string $group = null)MetaForm(below)type(MetaType|class-string),getType($name),types(),fieldType(Field),typeForValue($value)MetaFieldis a readonly object withkey,type(aMetaType),field(a stencilField, your own copy),translatableandgroup.Type inference:
Checkbox→booleanTextInput::numeric()→float; integer inputs need->metaType('integer')until State paths for nested fields, translatable fields without a translatable model, and isInteger() (for javaabu/meta) stencil#16 addsisInteger()DatePicker/DateTimePicker/TimePicker→date/datetime/timeTextarea/Wysiwyg→textSelect::multiple()/CheckboxList→jsonstring(255 characters at most)Relationship and media fields are refused.
Forms
The input names are the meta keys (
subtitle,tags[], andsubtitle_en/subtitle_dvfor translatable fields). When you embed the form in the post's own form, keep meta keys distinct from the post's attributes.Reading and writing
Query scopes
Semantics:
=,!=,<>,<,>,<=,>=,like,not like.=means "contains".!=andNotInonly match records that have the key.id IN (select metable_id from meta where metable_type = ? and key = ? and value_* …), which is index-only. Sorts are a left join on the unique record index. Numbers are on HasMeta and storage design #2.Custom types
Extend
Javaabu\Meta\Types\MetaType:name()andcolumn()(aMetaColumn: String, Text, Integer, Float, DateTime or Json)serialize()/unserialize()field($key)matches(Field),accepts($value)andrules()where()/whereIn()Register the type with
MetaFields::type(ColorType::class)orconfig('meta.types').n4ff4h commentedon Nov 5, 2026
Final summary
javaabu/meta v1.0 is on
main, and CI is green on every leg:javaabu/helpers, no settings v1)The suite has 118 PHPUnit tests. Every
next-genissue is closed except #10, which needs a human.What was built
value_string,text,int,float,datetimeandjson),(metable_type, key, value_*, metable_id)indexes and a unique record index. I chose it from benchmarks on MySQL 9.7 and SQLite 3.45 with 100k records and 800k meta rows; the numbers are on HasMeta and storage design #2 and indocs/advanced-usage/storage-and-performance.md. Models can opt into a table of their own with$meta_tableandMetaTable::columns(), and UUID or ULID keys are supported.HasMeta.metarelation's collection reads values by key, sodata_get($post, 'meta.subtitle')works.getMeta,setMeta(one upsert),removeMeta,syncMeta,getAllMetaandfillMeta.'meta' => [...]takes defined keys only.MetaFields::for(Model::class, [stencil fields], group:)registers fields, and records pick a group withmetaGroup(). Definitions can be closures orkey => typepairs, and parent-class definitions are inherited.MetaForm::for($record)is a stencil form for Blade and React. It validates with field and type rules, saves the meta, and can be embedded in the record's own form.->metaType(). CustomMetaTypes choose their column, casting, queries, field and rules.whereMeta,orWhereMeta,whereMetaIn,orWhereMetaIn,whereMetaNotIn,orWhereMetaNotIn,whereHasMeta,orWhereHasMeta,whereDoesntHaveMeta,orWhereDoesntHaveMeta,orderByMeta,orderByMetaDescandwithMeta.id IN (subquery), and sorts are left joins on the unique index.EXPLAINtests on both databases check that the indexes are used.Support\Translations.{key}_{locale}inputs are accepted.lang/enandlang/dv, Thaana in the tests. The package has no views of its own; forms render through stencil and forms.llms.txt(a test checks that every page is linked),AGENTS.md/CLAUDE.md, and the Boost guideline andjavaabu-metaskill.docs/advanced-usage/for-package-authors.md.Intentionally out of scope for v1
MetaForminside the app's own pages (or post-types'), so there are no routes or views to override.meta.in the record's own stencil form. Stencil can't re-path fields yet. Meta forms are separate or embedded, with input names that aren't prefixed (State paths for nested fields, translatable fields without a translatable model, and isInteger() (for javaabu/meta) stencil#16, nice to have).MetaForm. The form binds an array or a never-saved record, so stencil can't rebuild it. Conditions work.Follow-ups
TextInput::isInteger(),->integer()inputs would be stored as integers without->metaType('integer'); meta already uses the method when it exists.statePath()is optional.For the user
CODECOV_TOKENmust be available to this repo. Uploads usefail_ci_if_error: false.docs/advanced-usage/dhivehi-and-rtl.md#for-review.composer.json.Use stencil's isInteger(), isDeclaredTranslatable() and statePath()
n4ff4h commentedon Nov 5, 2026
Update: meta now uses the stencil APIs from Javaabu/stencil#16 (b83e2ad). Post-types, this is the part you build on.
Meta fields in the record's own form (new):
MetaFields::nested($record|$class, ?$group)returns a stencilGroupwithstatePath('meta')(MetaFields::STATE_PATH). It holds fresh copies of the record's fields, including itsmetaGroup()'s.meta[subtitle], state and rule paths aremeta.subtitle, and ids aremeta-subtitle. Values come fromdata_get($post, 'meta.subtitle'), which reads$post->meta.validate()returns['title' => …, 'meta' => [...]]. Stencil'ssave()/fillModel(),create()andupdate()passmetatoHasMeta, which writes the defined keys when the post is saved (new posts included).MetaFields::formFields($record|$class, ?$group)gives the bare field copies for a layout of your own.visibleWhen('meta.featured', true).Translatable meta in that form:
Javaabu\Stencil\Contracts\ResolvesTranslatableFields.HasMeta::isTranslatableField()is the implementation, so just addimplements ResolvesTranslatableFields.meta.*from the definitions, and the model's own fields through javaabu/translatable, so a translatablePostkeeps itstitle_en/title_dv.meta[subtitle_en]andmeta[subtitle_dv], and they save like a meta form's.MetaFields::isTranslatable($record|$class, $key, ?$group)answers per key.Other changes:
TextInput::integer()is now stored asintegerwithout->metaType('integer'), and->metaType()still overrides.Field::isDeclaredTranslatable(). The probe form is gone.MetaFormand->embedded()are unchanged. Stencil forms aren'tStringableany more, so render with{{ $form }}or->toHtml(), not(string) $form.The public API is unchanged apart from these additions; the only removal is the internal
TranslatableMetaRecord::probe(). Docs:docs/basic-usage/forms.md#inside-the-records-own-form,docs/advanced-usage/for-package-authors.md,docs/advanced-usage/translatable.md#in-the-records-own-form. Tests run on SQLite and MySQL, and without translatable.