Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Meta Laravel Package

kodeine/laravel-meta

Metable trait for Laravel Eloquent models adds fluent meta data support, letting you get, set, and unset metadata like normal model attributes. Requires Laravel 8+ and a per-model meta table migration.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and pick the right major version

composer require kodeine/laravel-meta   # ^2.0 for Laravel 8+

Version 2 dropped Laravel 7 support and removed the __get/__set/__isset trait-aliasing trick, so if you're inheriting an older codebase, read the upgrade notice in the README before touching composer. The main breakages: no legacy getSomething() → $model->something magic, getMeta($key, $default) now treats the second arg as a default, and whereMeta is now a scope (same call site, different placement).

Create the meta table first

The schema is the real integration surface — read this before writing any model code:

Schema::create('posts_meta', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->bigInteger('post_id')->unsigned();
    $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade');
    $table->string('type')->default('null');
    $table->string('key')->index();
    $table->text('value')->nullable();
    $table->timestamps();
});

Then attach the trait:

use Kodeine\Metable\Metable;

class Post extends Model
{
    use Metable;
}

Where to look first when something doesn't work

Three naming conventions decide everything, and all three are derived, not configured:

  1. Meta table = pluralized model name + _meta (Post → posts_meta).
  2. Foreign key = singular model name + _id (post_id).
  3. Fluent attribute lookup = "is this a column/accessor? if not, it's meta."

So the first debugging step on a blank result is to echo the resolved table and key:

dump($post->getMetaTable());   // confirm 'posts_meta'
dump($post->getMetaKeyName()); // confirm 'post_id'

Override with protected $metaTable = 'custom_meta'; and protected $metaKeyName = 'custom_fk'; when conventions don't fit.

First use case

The canonical win is a posts table that keeps growing optional columns — SEO title, canonical URL, hero image credit, feature flags, sync timestamps from third-party systems. Move those out of the schema and treat them as properties:

$post = Post::find(1);
$post->title   = 'Hello';                 // real column
$post->seo_title = 'Hello | My Site';     // meta, no migration needed
$post->save();                            // writes both tables

Get that working end-to-end (write, re-read in a fresh request, unset, verify row deleted) before you build anything on top of it. The whole package is one trait plus a hasMany relation, so if the round trip is clean, the rest is ergonomics.

Implementation Patterns

One writer path, chosen deliberately

There are four ways to write a meta and they behave differently. Pick a convention per project so reviewers aren't guessing:

// 1. Fluent, deferred until save() — best for controller/service code
$post->content = 'Some content';
$post->save();

// 2. Explicit single write, still deferred
$post->setMeta('content', 'Some content');
$post->save();

// 3. Mixed columns + metas in one call
$post->setAttributes([
    'title'   => 'Hello world',
    'content' => 'Some content',
    'views'   => 1,
]);
$post->save();

// 4. Write metas now, don't touch the parent row
$post->content = 'Some content';
$post->saveMeta();  // useful when the parent is dirty for unrelated reasons

saveMeta() is the one that catches people out in both directions: it skips the parent UPDATE (good for meta-only endpoints, no updated_at churn on the parent), but it also skips model events on Post, so anything listening to saved won't fire.

Bulk meta writes

setMeta and unsetMeta both accept arrays, plus comma/pipe-delimited strings of keys:

$post->setMeta(['content' => '...', 'views' => 1, 'revision' => 'draft']);
$post->unsetMeta('content|views');        // or 'content,views', or ['content','views']
$post->unsetMeta('content', 'views');     // variadic form

unsetMeta is silent when the key is absent — safe to call defensively.

Reads: single, multiple, defaults

$post->content;                                  // fluent
$post->getMeta('content');
$post->getMeta('content', 'fallback');           // default when missing
$post->getMeta(['content', 'views']);            // keyed collection, nulls for missing
$post->getMeta(['content', 'views'], 'none');    // one default applied to all misses
$post->getMeta(['content','views'], ['content' => 'x', 'views' => 0]); // per-key defaults

For batch reads inside a view or resource, use the array form once and index into the result — repeated single-key fluent access is fine but reads noisier and makes it harder to spot which keys you actually depend on.

Existence checks — prefer hasMeta

$post->hasMeta('content');
$post->hasMeta('content|views');   // true only if *all* exist
isset($post->content);             // works, but see the gotcha below

hasMeta is the honest primitive; it isn't affected by how the package's __isset treats a stored null.

Querying on meta: the meta scope and whereMeta

// Raw join, full control over grouping
Post::meta()
    ->where(function ($q) {
        $q->where('posts_meta.key', 'revision')
          ->where('posts_meta.value', 'draft');
    })
    ->get();

// Sugar — whereMeta($key, $value, $alias = null, $operator = '=')
Post::whereMeta('revision', 'draft')->get();
Post::whereMeta('views', 100, null, '>=')->get();

The $alias argument exists because a model can be joined to its meta table more than once; pass 'posts_meta'-style aliases when you need two meta predicates on the same query and find yourself fighting column ambiguity. For anything beyond two predicates, drop to meta() and group explicitly — it's more readable than a stack of whereMeta calls.

Also remember the join is a plain join: whereMeta narrows the base rows but does not deduplicate if multiple meta rows could match. Combine with distinct() or whereHas-style subqueries when a key isn't guaranteed unique.

Eager loading is not optional

$posts = Post::with('metas')->get();   // one extra query total

Any listing that renders meta values, exports to CSV, or builds an API resource must use with('metas'). Without it you get one query per model. This is the single most common production regression with this package, because it's invisible in a controller that fetches one post.

Defaults for sparse "exception-only" storage

$defaultMetaValues lets you store only deviations, which is the feature that makes this package genuinely more than a key/value table:

public $defaultMetaValues = [
    'is_user_home_sick' => false,
    'node_status'       => 'up',
];

Two consequences, both intentional: missing keys return the default, and writing a value equal to the default deletes the row. That keeps the meta table proportional to exceptions rather than to entity count — great for status flags and maintenance-mode markers where the overwhelming majority are in the normal state.

Serialization control

public $hideMeta = true;   // exclude metas from toArray()/toJson()

The README notes it also applies through relations, but be aware it's a model-level flag: you can't show metas on a detail endpoint and hide them on an index endpoint without reshaping the model instance or building resources that select fields explicitly. If your metas contain internal flags or PII, don't rely on the flag alone — define API resources and whitelist fields.

Events

metaCreating/metaCreated/metaSaving/metaSaved/metaUpdating/metaUpdated/metaDeleting/metaDeleted require use HasMetaEvents; on the model. Listeners receive the model and the meta key name:

User::metaSaving(function (User $user, string $key) {
    if ($key === 'email' && ! filter_var($user->getMeta('email'), FILTER_VALIDATE_EMAIL)) {
        return false; // aborts the save for *ing events
    }
});

The additional createdWithMetas, updatedWithMetas, savedWithMetas events do not need the trait and fire after the meta rows are flushed. Use them whenever a listener, observer, or dispatched job needs to read metas — created/saved fire before metas are written in that flow.

Inheritance

If Slideshow extends Post shares posts_meta, you must override the key name, or the child will look for slideshow_id:

class Slideshow extends Post
{
    protected function getMetaKeyName()
    {
        return 'post_id';
    }
}

This turns the meta table into a shared attribute bag across the hierarchy — powerful, but the whereMeta join now spans more than one model type, so queries need a discriminator on the parent table.

Gotchas and Tips

Defaults silently break whereMeta queries. Because writing the default value deletes the row, Post::whereMeta('node_status', 'up') returns nothing — those rows exist only in your $defaultMetaValues array, not in the table. Query the inverse (exceptions) or explicitly orWhereNull for absent keys with a left join. This is the most dangerous interaction in the package and it deserves a comment in the model.

The type column is not a morph column. It defaults to 'null' and is unrelated to polymorphism — each model gets its own meta table, and there's no supported way to share one meta table across unrelated models. If you have five metable models, you're writing five migrations and five tables. If you need one polymorphic key/value store, this package is the wrong shape; reach for a morphMany metadata table instead. Do a quick round-trip in tinker with a boolean, an integer, an array, and a JSON string to see exactly how values hydrate back before you persist non-strings at scale.

Fluid access collides with real attributes and accessors. Fluent lookup runs after the model's own attribute resolution, so a meta key that later becomes a column is shadowed without warning — the old value vanishes from reads and the meta row becomes dead weight. Keep meta keys in a documented namespace (seo_*, sync_*, flag_*) so promoting one to a column is a visible, intentional refactor.

Mass assignment applies to metas. fill(), update(), and create() all go through isFillable(), so meta keys must be in $fillable (or $guarded must allow them) or they'll be dropped silently — you'll see a 200 with no data written. Add the allowed meta keys to $fillable explicitly; avoid blanket $guarded = [] on any model that accepts request input, since it now also opens the meta surface.

isset() and stored nulls. If a meta is stored with a null value, isset($post->content) follows PHP's __isset convention and can report false even though the row exists. Use hasMeta() for existence and getMeta($key, $default) for value-with-fallback; never mix the two mental models.

Override drift. Metable overrides getAttribute and setAttribute. If you or a teammate has a custom setAttribute/getAttribute override, custom accessors, or a MutatesAttributes trait, ordering matters. When fluent access fights you, set protected $disableFluentMeta = true; and use getAttributeRaw() / setAttributeRaw() to reach the original Eloquent behavior — that's the clean escape hatch and worth documenting in the model.

Write amplification on imports and sync jobs. Each save() on a dirty meta model is one parent UPDATE plus one meta INSERT/UPDATE/DELETE per changed key. A 5,000-row sync becomes tens of thousands of round trips. For bulk loads, either batch inside DB::transaction() with chunked models, or write meta rows directly against the meta table and bypass the trait entirely — then hydrate with with('metas') afterwards.

Wrap parent + metas in a transaction. Metas flush on the model's saved event, after the parent UPDATE. Without DB::transaction(fn () => $post->setMeta([...])->save()) a mid-flight failure leaves a partially updated entity, and the parent's updated_at may have already moved.

Add the indexes you'll actually query on. The default migration indexes key alone. The common query shape is (post_id, key) for lookups and (key, value) for filtering — add both, and strongly consider a unique index on (post_id, key) to make duplicate-key races (two concurrent saveMeta calls on a fresh key) fail loudly instead of producing ambiguous reads.

Always ship the cascade. onDelete('cascade') on the foreign key is in the sample migration for a reason; without it, deleting a parent leaves orphan meta rows that quietly survive and can be resurrected if IDs are ever reused. Verify with Post::find(1)->metas after a delete on staging.

Watch for N+1 in resources and observers. Besides with('metas') on lists, check observers, model events, and Blade components that touch $model->some_meta — each is a separate query per model. Laravel's Model::preventLazyLoading() (or a DB::listen count assertion in tests) will surface these faster than manual inspection.

Escape key in raw SQL. It's a reserved word in MySQL. Eloquent quotes identifiers so the package's own queries are fine, but any hand-written whereRaw or report query needs backticks.

Testing. RefreshDatabase plus factories work normally — set metas in factory states and call save():

Post::factory()->create(['title' => 'x']); // meta keys go through the fluent setter, not the array

Note that factory attribute arrays route through fill, so metas defined there need to be fillable (see above) or set via a ->afterCreating() hook.

Maintenance posture. MIT, ~419 stars, a small maintainer surface, and a version 2 with real breaking changes. It's stable and the trait/relation design is simple enough to fork or vendor if needed — but for metadata that's central to your domain (audit trails, permissions, pricing), weigh it against a polymorphic schemaless-attributes approach where you control the query surface. Use this package where metas are genuinely peripheral and sparse; promote anything hot into real columns with indexes.

Debugging checklist, in order: confirm getMetaTable() / getMetaKeyName(); confirm the meta key is in $fillable if you're using fill/update; confirm you're not relying on whereMeta for a value equal to a default; confirm with('metas') on the query; confirm you didn't set disableFluentMeta; then dump $post->getMeta()->toArray() and inspect the raw rows.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource