Skip to content

Next-gen: javaabu/meta v1.0 #11

Open

Description

@n4ff4h

Meta fields for Eloquent models, defined with stencil, efficient to query with scopes, extensible with custom field types, optionally translatable.

Package: javaabu/meta · Target: v1.0 · Wave: 3
Depends on: javaabu/stencil
Optional: javaabu/translatable

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.

Checklist

Developed by an agent orchestrated from the Javaabu next-gen plan. Cross-package requests go in the dependency's repo with the cross-package label.

Activity

  1. added
    next-genNext-gen Javaabu packages
    epicTracks a whole package
    on Nov 4, 2026
  2. n4ff4h commented on Nov 5, 2026

    @n4ff4h
    CollaboratorAuthor

    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:

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

    • Scopes (Query scopes #4).

      • 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.
  3. n4ff4h commented on Nov 5, 2026

    @n4ff4h
    CollaboratorAuthor

    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

    use Javaabu\Meta\Concerns\HasMeta;
    
    class Post extends Model
    {
        use HasMeta;
    
        // Optional: fields for some records only (a post type).
        public function metaGroup(): ?string
        {
            return $this->type;
        }
    
        // Optional: keep this model's meta in its own table, created with MetaTable::columns($table, 'post_meta').
        protected string $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
    Call Returns
    fields(Model|string $model, ?string $group = null) array<string, MetaField>: the model's own fields, then the group's. A record uses its metaGroup()
    field($model, $key, $group = null) / keys(...) / groups(Post::class) ?MetaField / array<int, string> / array<int, string>
    queryField(Post::class, $key) The field from the model or any group. Throws if groups give the key different types
    form(Model $record, ?string $group = null) MetaForm (below)
    type(MetaType|class-string), getType($name), types(), fieldType(Field), typeForValue($value) Types

    MetaField is a readonly object with key, type (a MetaType), field (a stencil Field, your own copy), translatable and group.

    Type inference:

    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 rules
    MetaForm::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 it
    Post::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

    Semantics:

    • Operators: =, !=, <>, <, >, <=, >=, like, not like.
    • JSON values: = means "contains".
    • != 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').

  4. n4ff4h commented on Nov 5, 2026

    @n4ff4h
    CollaboratorAuthor

    Final summary

    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.
    • Scopes (Query scopes #4).
      • The scopes are whereMeta, orWhereMeta, whereMetaIn, orWhereMetaIn, whereMetaNotIn, orWhereMetaNotIn, whereHasMeta, orWhereHasMeta, whereDoesntHaveMeta, orWhereDoesntHaveMeta, orderByMeta, orderByMetaDesc and withMeta.
      • Conditions are index-only id IN (subquery), and sorts are left joins on the unique index.
      • EXPLAIN tests on both databases check that the indexes are used.
    • Translatable (Optional javaabu/translatable support #9). This is optional, and every check goes through Support\Translations.
      • 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.
    • Docs, Boost and AGENTS (Documentation for people and agents #6, Laravel Boost guideline and skill #7). Docusaurus docs, llms.txt (a test checks that every page is linked), AGENTS.md/CLAUDE.md, and the Boost guideline and javaabu-meta skill.
    • 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.
    • Fields nested under 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).
    • Live fields in MetaForm. The form binds an array or a never-saved record, so stencil can't rebuild it. Conditions work.
    • PostgreSQL and MariaDB. They aren't tested; MySQL and SQLite are.

    Follow-ups

    For the user

    • Codecov. CODECOV_TOKEN must be available to this repo. Uploads use fail_ci_if_error: false.
    • Docs registration. Register the package with docs.javaabu.com (Register with Javaabu Docs (docs.javaabu.com) #10).
    • 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.
  5. n4ff4h commented on Nov 5, 2026

    @n4ff4h
    CollaboratorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    epicTracks a whole packagenext-genNext-gen Javaabu packages

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions