elhareth/laravel-eloquent-metable
elhareth/laravel-eloquent-metable adds flexible key-value metadata to any Eloquent model via a simple trait, letting you attach custom attributes without extra migrations or schema changes.
Install and publish
composer require elhareth/laravel-eloquent-metable
php artisan vendor:publish --provider="Elhareth\Metable\MetableServiceProvider"
php artisan migrate
Publish both the config and the migrations in one go — the migration is the piece people forget, and you'll get confusing "no such table: meta" errors on first save otherwise. Check config/metable.php first; it holds the table name and (as of v1.0.0) the key type used for meta rows.
Attach the trait
use Elhareth\Metable\HasMeta;
class Product extends Model
{
use HasMeta;
}
First use case: ship a small, schema-free attribute bag on a model whose shape isn't known yet — per-tenant feature flags, CMS page settings, product spec sheets, onboarding answers. The moment you find yourself writing add_column_xyz for a field only 5% of rows use, that's the signal to reach for this package.
The v1.0.0 release is the first stable tag, so treat it as the baseline. The most notable functional addition is ULID support for meta records, which matters if the rest of your schema is already ULID/UUID-based.
Method names below follow the package's documented conventions — confirm the exact signatures in the README before committing, especially if you're porting from another metable package.
Single-value read/write
$product->setMeta('weight_kg', 1.4);
$product->getMeta('weight_kg'); // 1.4
$product->getMeta('missing', 'fallback'); // 'fallback'
Use this in form request handlers and admin panels where the field set is dynamic. Pair it with a $fillable-style allowlist of meta keys so you're not writing arbitrary user input straight into the bag.
Bulk sync (the pattern you'll actually use most)
$product->syncMeta([
'weight_kg' => 1.4,
'colour' => 'slate',
'hazmat' => false,
]);
syncMeta in a single call is what you want inside a DB::transaction() — it avoids per-key round trips and makes partial writes less likely. Never loop setMeta() inside a request without a transaction.
Reading efficiently
$products = Product::with('meta')->paginate(25);
The meta relation is a polymorphic has-many. Eager-load it anywhere you render meta in a list; without it you get one query per row per key. For index pages, consider selecting only the keys you need:
Product::with(['meta' => fn ($q) => $q->whereIn('key', ['colour', 'weight_kg'])])
Querying by meta
Use the provided scope rather than hand-rolling joins:
Product::whereMeta('colour', 'slate')->get();
Anything more complex (numeric ranges, LIKE, ordering by a meta value) is where this approach starts to hurt — the values column is typically a string/text column, so comparisons are lexicographic unless you cast. For genuine filtering requirements, promote the field to a real column and keep meta for display-only data.
API and resource layers
class ProductResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'meta' => $this->meta->pluck('value', 'key'),
];
}
}
Flattening to key => value is friendlier for the frontend than the raw collection of meta models, and it lets you apply casts in one place.
ULID meta records (new in v1.0.0)
If your app uses ULIDs, set the key type in config before running migrations, then re-publish. Meta models will need $keyType = 'string' and $incrementing = false if you've extended the base meta model. Do this on a fresh install or a dedicated migration — not mid-flight on a populated table.
Custom meta model
If you need extra columns on meta rows (audit trail, source, ordering), extend the package's meta model, point the config at it, and add the columns in your own migration. This is the cleanest extension point and avoids forking.
composer.json until you've verified.boolean false and "false" are indistinguishable after a round trip unless you normalise. For structured data, json_encode/json_decode explicitly rather than hoping the package handles arrays.value column type is the usual culprit for silent truncation on long text. If you're storing JSON blobs or long descriptions, widen it in your own migration.syncMeta. Add a unique index on (model_type, model_id, key) in a follow-up migration if integrity matters — and be aware it changes setMeta semantics from "append" to "replace".with('meta') is 51 queries.$model->meta is a collection, not an array. $model->meta['colour'] won't do what you expect. Use getMeta() or ->meta->pluck('value', 'key').$model->meta()->toSql() and bindings when a whereMeta scope returns nothing — the polymorphic type column (model_type) must match the fully-qualified class name exactly, and a renamed class or a model with a custom morph map will silently break lookups.RefreshDatabase and assert on getMeta() rather than raw table rows, so a future schema change (ULID keys, widened columns) doesn't break your suite for the wrong reason. Add at least one test that asserts a null/missing key returns the default, since that's where behaviour differs between metable packages.whereMeta chains three deep with casts and orderBy, you've built a database in the wrong place — add the column.How can I help you explore Laravel packages today?