zoha/laravel-meta
zoha/laravel-meta lets Laravel apps attach and manage SEO metadata for Eloquent models and pages. Define titles, descriptions, Open Graph and Twitter tags, store custom meta polymorphically, and render clean meta tags in Blade via a simple, configurable API. Ideal for blogs, CMS pages, and product c...
Install and publish
composer require zoha/laravel-meta
php artisan vendor:publish --provider="Zoha\Meta\MetaServiceProvider"
php artisan migrate
This package is a polymorphic key/value store for Eloquent models. It creates a single meta table (metable_type, metable_id, key, value) rather than forcing you to add a nullable column for every new attribute. That shape is the whole point of the package — understand it before you use it.
Look at these first, in order:
config/meta.php (if published) — table name, column names, value casting options.value column type (text vs json) tells you how much you can store per key.HasMeta / MetaTrait) — this is the actual API surface; read it, don't guess method names from blog posts.Meta model — it's the extension point if you need custom behaviour.Attach the trait to the models that need metadata:
use Zoha\Meta\Traits\HasMeta;
class Post extends Model
{
use HasMeta;
}
First use case — per-record settings that don't deserve a column:
$post->setMeta('seo_title', 'Laravel Meta in Practice');
$post->setMeta('og_image', '/img/og/42.png');
$post->getMeta('seo_title'); // 'Laravel Meta in Practice'
$post->getMeta('missing', 'fallback'); // 'fallback'
That is the 80% case: a handful of sparse, optional, per-model attributes (SEO fields, UI preferences, feature flags, third-party IDs) where adding real columns would mean a migration every sprint.
Wrap it — don't call the trait from controllers everywhere. Metadata has no schema, so an accidental key typo silently creates a new row. Centralise the key vocabulary:
class PostMeta
{
public const SEO_TITLE = 'seo_title';
public const OG_IMAGE = 'og_image';
public static function defaults(): array
{
return [self::SEO_TITLE => null, self::OG_IMAGE => null];
}
}
// In a form request / action
$post->setMeta(PostMeta::SEO_TITLE, $request->validated('seo_title'));
Bonus: defaults() gives you a stable shape for API resources and Blade forms.
Store structured values as arrays, not concatenated strings. If value is JSON/text-backed, cast on write and read:
$post->setMeta('blocks', ['hero', 'cta', 'footer']);
$post->getMeta('blocks'); // decode yourself, or use a cast on the Meta model
If you find yourself serialising by hand, add an accessor/mutator on a custom Meta model instead. Keeping the cast in one place means one fix if the package's storage format changes.
Bulk set in an action, guarded by a transaction. Meta writes are usually one query per key; a form saving eight keys does not belong in a loop inside a controller.
DB::transaction(function () use ($post, $data) {
$post->setMeta($data); // if the package supports array input
// or: foreach ($data as $key => $value) { $post->setMeta($key, $value); }
$post->save();
});
Read-side: hydrate once, render everywhere. In a resource or view composer:
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'meta' => array_merge(
PostMeta::defaults(),
$this->meta->pluck('value', 'key')->all()
),
];
}
}
Pulling $this->meta once and turning it into a plain array beats N getMeta() calls when rendering a list.
Eager load for index pages. Whatever the relation is called (meta / metas), add it:
$posts = Post::with('meta')->paginate();
Filtering. Metadata is not a good primary query surface. If you need "all posts with featured = true" at scale, either add a real indexed column for that flag and keep meta for the long tail, or query the pivot table directly with a whereHas on the meta relation — and accept the join cost. The key/value table cannot be index-optimised for arbitrary key lookups the way a column can.
Testing. Set meta in factories after creation, and give yourself a helper so tests read as intent rather than plumbing:
$post = Post::factory()->create();
$post->setMeta('seo_title', 'Test');
$this->assertDatabaseHas('meta', [
'metable_id' => $post->id, 'key' => 'seo_title',
]);
Migrating existing columns. If you're moving seo_title off the model, write a one-off command that copies column → meta, ship both reads for one deploy, then drop the column. Don't do both in a single migration.
The value column has a length ceiling. Long-form content (a whole HTML block, a big JSON blob) will silently truncate on text/varchar. Keep meta for small, atomic values; put documents in their own table or a JSON column on the model.
Everything comes back as a string unless the package casts. $post->getMeta('is_published') returning "1" rather than true breaks if checks and JSON APIs subtly. Cast explicitly at the boundary — a getMetaBool/getMetaInt wrapper or an accessor on the Meta model — rather than trusting ==.
Silent key collisions and drift. There's no schema, so seoTitle, seo_title, and seo-title are three different rows that all "work". The constants class above is the cheapest fix. Add a cleanup command later if drift already happened.
Watch for N+1 on collections. $post->getMeta('x') inside a @foreach will re-query unless the relation is loaded and the accessor reads from it. Profile with DB::listen / Telescope before you ship a list page.
Meta rows outlive their parent. Soft deletes and hard deletes don't cascade — polymorphic associations never do. Add a deleting hook on your models or a scheduled orphan sweep:
static::deleting(fn ($model) => $model->meta()->delete());
Without this, metable_id values get reused and a new model can inherit a dead model's metadata. This is the nastiest bug this pattern produces, and it's silent.
Concurrency. Two requests setting the same key can race into duplicate rows if there's no unique index on (metable_type, metable_id, key). Check the published migration; if it's missing, add the unique index and switch to an updateOrCreate-style write.
Eager-load naming is easy to get wrong. Confirm the actual relation name in the trait before writing with('meta') in five places — a typo means eager loading silently does nothing and you're back to N+1.
Extension points, in preference order: (1) a config value override for the table name, (2) your own Meta model subclass bound via config, (3) a service/repository layer of your own. Prefer (1) and (3). Directly subclassing package models couples you to internals.
Maintenance posture. Last release 2024-03 and a small star count means this is a "boring and probably finished" package rather than an actively evolving one — which is fine for a single-table CRUD trait, but it means don't expect PHP/Laravel version bumps to land quickly. Pin the version in composer.json, keep your meta access behind your own PostMeta/resource layer as described above, and do a quick compat check when you upgrade Laravel majors. If it ever stops working, the replacement is roughly 80 lines of trait, a migration, and a model — which is exactly why wrapping it costs you nothing and buys you an exit.
How can I help you explore Laravel packages today?