appstract/laravel-meta
Appstract Laravel Meta makes it easy to add, manage, and render SEO-friendly meta tags in Laravel apps. Define titles, descriptions, Open Graph and Twitter tags in PHP, then output them cleanly in your Blade views.
1. Install the package
composer require appstract/laravel-meta
The package targets the Laravel 5.5–5.8 era. On 5.5+ the Appstract\Meta\MetaServiceProvider is auto-discovered; on older versions register it manually in config/app.php.
2. Publish and run the migration
php artisan vendor:publish --provider="Appstract\Meta\MetaServiceProvider"
php artisan migrate
This creates a single polymorphic meta table (metable_type, metable_id, key, value, timestamps). There is no config file worth mentioning — the schema is the configuration.
3. Attach the trait to any model
use Appstract\Meta\HasMeta;
class Post extends Model
{
use HasMeta;
}
4. First use case
The canonical day-one use case is "a handful of per-record flags/settings I don't want columns for":
$post->setMeta('reading_time', 4);
$post->getMeta('reading_time'); // "4"
$post->getMeta('missing', 'fallback'); // "fallback"
$post->hasMeta('reading_time'); // true
$post->removeMeta('reading_time');
Where to look first: open vendor/appstract/laravel-meta/src/HasMeta.php before writing anything real. The whole package is only a few hundred lines, and the public API is small — reading the trait takes five minutes and prevents 90% of the surprises below. Note the exact trait namespace on your installed tag (Appstract\Meta\HasMeta vs. a Traits\ sub-namespace) rather than trusting the README from 2018.
Settings bag on a model. The most common pattern — a User or Team with a meta relationship acting as a sparse settings store:
$user->setMeta('timezone', 'Europe/Amsterdam');
$user->setMeta('digest_frequency', 'weekly');
Prefer this over json columns when different parts of the app (and different packages) own different keys and you want to read/write them independently.
Controller / form pattern. Map validated request fields to meta keys in a loop instead of one line per field:
$settings = $request->validate([
'timezone' => 'required|timezone',
'digest_frequency' => 'in:daily,weekly,never',
])['settings'] ?? [];
foreach ($request->only(['timezone', 'digest_frequency']) as $key => $value) {
$user->setMeta($key, $value);
}
Bulk updates belong in a transaction. There's no "set many" method, so wrap loops yourself:
DB::transaction(function () use ($user, $data) {
foreach ($data as $key => $value) {
$user->setMeta($key, $value);
}
});
Reading everything at once. The meta() relation yields Meta models, so hydrate a plain array for views:
$meta = $post->meta->pluck('value', 'key')->toArray();
Eager-load it when rendering lists: Post::with('meta')->paginate().
Querying by metadata. Treat meta like a filter table and use whereHas:
Post::whereHas('meta', function ($q) {
$q->where('key', 'featured')->where('value', '1');
})->get();
Keep this pattern out of hot paths — see the indexing note below.
Seeding / factories. Seed meta after the parent exists, since metable_id needs a real key. Add a HasMeta helper state or a small withMeta([...]) factory method in your test suite rather than hardcoding keys in feature tests.
Cast at the edges, not in the database. Store scalars as strings and convert in an accessor or a small value-object wrapper ($post->getMeta('published_at') → Carbon::parse(...)). The package does no casting, which is fine — just be consistent about it in one place.
Morph map early. If you attach the trait to several models, register Relation::morphMap() at boot so metable_type stores 'post' rather than the FQCN. Renaming a model later otherwise orphans its metadata silently.
It's unmaintained (last release Jan 2018). Before adopting, actually install it on your Laravel version and run the migration. Expect to adjust the migration (column lengths, index support) and to re-check the trait if you're on PHP 8+. Treat the package as a snippet library you can fork, not an actively maintained dependency.
No type casting — booleans are a trap. setMeta('is_active', false) stores an empty string; getMeta('is_active') returns "", not false. Normalize explicitly:
$post->setMeta('is_active', $flag ? '1' : '0');
filter_var($post->getMeta('is_active'), FILTER_VALIDATE_BOOLEAN);
Arrays/objects need json_encode on write and json_decode on read — always paired, or you'll get strings where you expect structures.
getMeta returns null for missing keys, so $post->getMeta('x') ?? 'default' is your friend. Be careful with the default argument form — don't assume a missing key and a key deliberately set to '0' are distinguishable unless you check hasMeta().
Check the value column width in the published migration. If it's a string(255) on your tag, long text (bio, notes, markdown) will either truncate or throw depending on MySQL strict mode. Change it to text in the migration before migrate in production.
N+1 is the default. Serializing a collection of models with $model->meta triggers a query each. Always with('meta') on index endpoints. If you only need one key in a list view, select it in a withCount/whereHas instead of loading the whole collection.
Add an index. The stock migration likely doesn't index (metable_type, metable_id, key). Add a composite index (and consider making it unique if you want to guarantee one row per key). Without it, whereHas filters on meta become full scans once the table grows.
Orphaned rows. Deleting a model does not necessarily remove its meta rows unless cascading is configured. Add cleanup — a deleted model event, a scheduled prune, or a proper FK with onDelete('cascade') if your schema allows polymorphic-agnostic cleanup (usually a job, not an FK).
Watch for duplicates. If setMeta is implemented as create-then-update (typical), concurrent requests can race and leave two rows for the same key. Wrap writes in a transaction and/or add the unique index above; on read, decide whether pluck('value','key') (last wins) or strict first-match is your contract.
Class renames break everything. Because metable_type stores the class name by default, renaming Post → Article silently detaches all metadata. Register a morph map on day one, and if you must rename, write a one-off migration that rewrites metable_type.
Debugging. When a value "doesn't save," go straight to the table: DB::table('meta')->where('metable_id', $post->id)->get(). Nine times out of ten it's a mismatched key name, a truncated value, or a stored "" that looks falsy in a Blade conditional.
Extension points are thin. You can subclass/replace the Meta model or add your own casting layer around the trait, but there's no config for table name, key column, or casting. If you need typed casts, per-key validation, or schemaless JSON, evaluate spatie/laravel-schemaless-attributes or a dedicated settings package before committing — for a greenfield project in 2024+, that trade-off is usually worth making.
How can I help you explore Laravel packages today?