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.
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:
_meta (Post → posts_meta)._id (post_id).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.
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.
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.
How can I help you explore Laravel packages today?