jobmetric/laravel-metadata
A Laravel package for attaching flexible key-value metadata to Eloquent models. It offers polymorphic metadata storage, simple trait-based integration, and helpers to set, get, update, and query metadata without changing your existing database schema.
The package adds a polymorphic key/value metadata store to any Eloquent model. The minimal path looks like this:
Install and publish
composer require jobmetric/laravel-metadata
php artisan vendor:publish --provider="Jobmetric\Metadata\MetadataServiceProvider"
php artisan migrate
You should now have a metadata table (morphs + key + value columns). Check the published migration first — that's the single best place to understand what the package actually stores.
Look at the source before trusting it. At 11 stars and a 0.115 score, this is a low-traffic package. Open src/ first: read the trait (usually HasMetadata), the Metadata model, and the config file. Ten minutes here will save you guessing at method names later.
Attach the trait to a model
use Jobmetric\Metadata\HasMetadata;
class Product extends Model
{
use HasMetadata;
}
First use case — the canonical one is a flexible sidecar for admin-editable fields that don't warrant a column: SEO meta title/description, per-product display flags, feature toggles, third-party IDs, or localized marketing copy. If a value is queried/filtered constantly or needs a DB constraint, use a real column; metadata is for "extra fields that admins add without a migration."
Try one round-trip before building anything on it:
$product->setMetadata('seo_title', 'Red Shoe');
$product->getMetadata('seo_title'); // verify the actual method name in the trait
Wrap it in your own service/repository. Don't scatter setMetadata() calls across controllers. Create a small ProductMetadata class that knows the whitelist of allowed keys, casts types, and applies defaults:
class ProductMetadata
{
public const KEYS = ['seo_title', 'seo_description', 'badge'];
public function __construct(private Product $product) {}
public function all(): array
{
return collect(self::KEYS)
->mapWithKeys(fn ($k) => [$k => $this->product->getMetadata($k)])
->all();
}
public function sync(array $data): void
{
foreach (array_intersect_key($data, array_flip(self::KEYS)) as $key => $value) {
$this->product->setMetadata($key, $value);
}
}
}
Form + validation pattern. Treat metadata like any other form field: validate with Rule::in(ProductMetadata::KEYS) then loop the whitelist in the request handler. This keeps "unknown key" injections and typos out of your table.
Typed values. Metadata tables are strings. Either pick a convention (JSON-encode all values, decode on read) or use the package's type support if it has one. Consistency matters more than which choice you make — mixed scalar-vs-JSON values in the same column is a future debugging session.
Bulk reads for indexes. If you render a product list with metadata, don't N+1. Eager-load the metadata relationship once, then read keys off the collection — or add a scoped "load all metadata for these IDs" query in your repository.
Admin CRUD builder. The natural fit is a generic admin screen: list Product::with('metadata'), show a key/value editor grid, and persist with a diff (add/update/delete). This is where metadata shines because you don't ship a migration for every new field marketing wants.
Settings / app-wide config. Because the relationship is polymorphic, you can attach metadata to a singleton "Settings" model or an App\Settings row to get a lightweight key/value settings store — but do not use this for values that need type safety or auditing. Prefer config/ or a dedicated settings package for that.
Migration / sync helper. Keep a metadata:backfill artisan command that reads canonical keys from your code and writes defaults onto existing records. When you add a new metadata key, you can run it once to seed nulls/booleans so consumers don't have to handle missing keys.
Cache with a tag. Metadata is a per-model read on nearly every page. Cache the whole metadata array per model ID, invalidate on save/delete in your service layer. Use Cache::tags(['metadata', "product:{$id}"]) if you're on Redis/Memcached.
src/ before writing tutorials. This package's method names and return shapes are not documented at any scale. Grep the trait for public function and read the Metadata model's casts/fillable — that's your real contract.(metadataable_type, metadataable_id, key) you can get duplicates. Add the unique index in a follow-up migration if it's missing.$model->metadata()->delete() in an observer's deleting hook, or a Model::deleted listener — otherwise your table grows silently.setMetadata() per key in a request means N writes. Wrap the whole sync in DB::transaction() and consider an upsert() on the metadata table for large payloads.value to JSON, setMetadata('x', 'plain string') may store "plain string" and read it back fine — but manually inspecting the DB in a query tool becomes confusing. Document the convention.getMetadata('x') ?? config('product.defaults.x') is the pattern; if the package throws on missing keys, wrap your own accessor.config/metadata.php before going live — the table name, morph key names, and any type/length limits live there. Changing the table name post-migration means editing the migration and the config together.morphs() and unique indexes behave slightly differently from MySQL. Run at least one integration test against your real DB engine.composer.json (e.g. "jobmetric/laravel-metadata": "~1.0") rather than *, and skim CHANGELOG/diffs on bumps.How can I help you explore Laravel packages today?