daikazu / laravel-meta
This is my package laravel-meta
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^11.0||^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
⚠️ Work in progress. This package is under active development and not yet stable. APIs may change without notice and it is not recommended for production use yet.
Typed, casted, queryable key-value metadata for any Eloquent model.
- One
$model->metaproperty gives you a full read/write/delete API. - Dot-notation for nested JSON values.
- Built-in casts (
string,integer,float,boolean,array,json,collection,datetime,encrypted) plus custom casts and zero-boilerplate plain value objects. - Two storage drivers: separate
model_metatable (default) or a JSON column on the model's own table. - Query scopes:
whereMeta,whereMetaIn,whereMetaBetween,whereMetaNull,orderByMeta, and more. - Optional in-memory caching, event broadcasting, schema validation, computed meta, and an indexed sidecar table for hot keys.
- 64 tests (Pest), PHPStan max, Laravel 11 / 12 / 13.
Requirements
- PHP 8.3+
- Laravel 11, 12, or 13
Installation
composer require daikazu/laravel-meta
Run the install command to publish the config, publish the migrations, and optionally run them:
php artisan meta:install
The command publishes two tags:
laravel-meta-config→config/meta.phplaravel-meta-migrations→ two migration stubs (create_model_meta_table,create_model_meta_index_table)
To force-overwrite existing published files:
php artisan meta:install --force
Model Setup
Add the HasMeta trait to any Eloquent model:
use Daikazu\LaravelMeta\Concerns\HasMeta; use Illuminate\Database\Eloquent\Model; class Product extends Model { use HasMeta; }
That's it. The $model->meta property is now a MetaBag instance.
Optional: the HasMetadata interface
The HasMeta trait already provides everything, but you can additionally implement the HasMetadata interface for stronger typing. It's purely additive — no behaviour changes — and lets static analysis and other packages (such as daikazu/filament-meta) accept "a model with metadata" without falling back to mixed:
use Daikazu\LaravelMeta\Concerns\HasMeta; use Daikazu\LaravelMeta\Contracts\HasMetadata; use Illuminate\Database\Eloquent\Model; class Product extends Model implements HasMetadata { use HasMeta; }
The interface declares a single method, meta(): MetaBag (and the @property-read MetaBag $meta accessor), both of which the trait already satisfies — so implementing it never requires extra code.
Basic Usage
Reading
$product->meta->get('color'); // mixed, null when absent $product->meta->get('color', 'red'); // with default // Typed accessors — each returns null when the key is absent $product->meta->string('title'); // ?string $product->meta->integer('views'); // ?int $product->meta->float('rating'); // ?float $product->meta->boolean('featured'); // ?bool $product->meta->array('tags'); // ?array $product->meta->collection('tags'); // Collection<array-key, mixed> $product->meta->datetime('published_at'); // ?CarbonImmutable // All keys at once $product->meta->all(); // array<string, mixed>
Writing
$product->meta->set('color', 'blue'); // Multiple keys in one call (single DB round-trip for non-dot keys) $product->meta->setMany([ 'color' => 'blue', 'featured' => true, 'rating' => 4.5, ]); // Alias for setMany $product->meta->upsert(['color' => 'green']); // Sync — identical to setMany but removes keys not in the payload $product->meta->sync(['color' => 'green', 'weight' => 1.2]); // keys not listed ('featured', 'rating' above) are deleted
Deleting
$product->meta->forget('color'); $product->meta->forgetMany(['color', 'rating']); $product->meta->flush(); // remove every key for this model
Existence
$product->meta->has('color'); // bool $product->meta->missing('color'); // bool
Dot-Notation
Dot notation reads and writes into nested JSON values stored under the top-level key:
$product->meta->set('seo.open_graph.title', 'My Product'); $product->meta->get('seo.open_graph.title'); // 'My Product' $product->meta->get('seo'); // ['open_graph' => ['title' => 'My Product']]
Buffering on Unsaved Models
Writing to ->meta before the model is saved is safe. Values are buffered and flushed automatically inside the saved hook:
$product = new Product(['name' => 'New']); $product->meta->set('featured', true); $product->save(); // buffered meta is written here
Casting
Declared Casts
Define $metaCasts on your model to control how values are serialised and hydrated:
class Product extends Model { use HasMeta; protected array $metaCasts = [ 'featured' => 'boolean', 'rating' => 'float', 'published_at' => 'datetime', 'tags' => 'array', 'seo' => SeoData::class, // value object (see below) ]; }
Built-In Cast Aliases
| Alias | Notes |
|---|---|
string |
|
integer / int |
|
float / double |
|
boolean / bool |
|
array |
PHP array, JSON-encoded at rest |
json |
Alias for array |
collection |
Illuminate\Support\Collection |
datetime |
CarbonImmutable |
encrypted |
Encrypts the raw value using Laravel's encrypter |
Custom Cast Classes
Implement Daikazu\LaravelMeta\Contracts\MetaCast:
use Daikazu\LaravelMeta\Contracts\MetaCast; use Illuminate\Database\Eloquent\Model; class MoneyCast implements MetaCast { public function get(mixed $value, string $key, Model $model): Money { return new Money((int) $value['amount'], (string) $value['currency']); } public function set(mixed $value, string $key, Model $model): array { return ['amount' => $value->amount, 'currency' => $value->currency]; } }
Then use the FQCN in $metaCasts:
protected array $metaCasts = [ 'price' => MoneyCast::class, ];
The Castable Contract
For classes that produce their own cast, implement Daikazu\LaravelMeta\Contracts\Castable:
use Daikazu\LaravelMeta\Contracts\Castable; use Daikazu\LaravelMeta\Contracts\MetaCast; class Money implements Castable { public static function castUsing(): MetaCast { return new MoneyCast; } }
CastManager calls ::castUsing() automatically when it sees a Castable class.
Plain Value Objects (Zero Boilerplate)
Any class that is not a MetaCast or Castable is automatically wrapped in ValueObjectCast, which serialises and hydrates via the constructor's public promoted properties. No custom cast class needed:
// The value object — no cast interface required final readonly class SeoData { public function __construct( public string $title, public ?string $description = null, ) {} } // Model protected array $metaCasts = [ 'seo' => SeoData::class, ]; // Usage $product->meta->set('seo', new SeoData(title: 'My Product', description: 'Great.')); $seo = $product->meta->get('seo'); // SeoData instance
Global Custom Casts via Config
Register additional aliases in config/meta.php so every model can use them by alias name:
'casts' => [ 'money' => \App\Casts\MoneyCast::class, ],
Encrypted Meta
Two equivalent ways to encrypt a value at rest:
// 1. Declare the cast protected array $metaCasts = ['api_key' => 'encrypted']; // 2. Fluent modifier (does not require a declared cast) $product->meta->encrypted()->set('api_key', 'sk-...'); $product->meta->encrypted()->setMany(['token' => 'abc', 'secret' => 'xyz']);
The encrypted() modifier applies only to the immediately following set() or setMany() call.
Storage Drivers
Table Driver (Default)
Stores each top-level key as a row in the model_meta table. This is the default and requires no per-model configuration.
config/meta.php → 'driver' => 'table'
JSON Column Driver
Stores all meta for a model in a single JSON column on the model's own table. Add the column and cast to your model:
// Migration snippet $table->json('meta_data')->nullable(); // Model class Page extends Model { use HasMeta; protected string $metaColumn = 'meta_data'; protected $casts = ['meta_data' => 'array']; }
When $metaColumn is defined, the JSON driver is selected automatically regardless of the global driver config. You can also set protected string $metaDriver = 'json' explicitly, in which case $metaColumn falls back to the json_column config value (meta_data by default).
Driver Resolution Order
protected string $metaDriver = '...'on the model — explicit override.protected string $metaColumn = '...'on the model — impliesjsondriver.meta.driverconfig key — global default (table).
Query Scopes
All scopes are available on every model that uses HasMeta.
// Equality (shorthand: omit operator for '=') Product::whereMeta('featured', true)->get(); Product::whereMeta('rating', '>', 4)->get(); // OR variant Product::whereMeta('featured', true) ->orWhereMeta('featured', false) ->get(); // IN / NOT IN Product::whereMetaIn('category', ['books', 'games'])->get(); Product::whereMetaNotIn('status', ['archived'])->get(); // NULL / NOT NULL (works with dot-notation keys) Product::whereMetaNull('seo.description')->get(); Product::whereMetaNotNull('published_at')->get(); // BETWEEN Product::whereMetaBetween('rating', [3, 5])->get(); // ORDER BY Product::orderByMeta('rating', 'desc')->get();
Eager Loading
withMeta() is the canonical scope for eager-loading metadata. It loads the underlying metaRecords relation so the MetaBag can prime itself without additional queries:
$products = Product::withMeta()->get(); // Each $product->meta->get('...') now reads from memory.
Why withMeta() and not with('meta')?
$model->meta is a computed property (accessor) that returns a MetaBag — it is not an Eloquent relation. The underlying polymorphic relation is metaRecords. withMeta() calls ->with('metaRecords') under the hood and is the intended API.
// Also available directly when you need the raw relation $product->metaRecords; // Collection<int, Meta>
Caching
Caching is enabled by default and wraps every repository read with the configured cache store.
// config/meta.php 'cache' => [ 'enabled' => true, // set false to disable entirely 'store' => null, // null = default cache store; or e.g. 'redis' 'ttl' => 3600, // seconds 'prefix' => 'meta', ],
Clear the cache manually:
php artisan meta:cache:clear
Events
Events are dispatched after every write (enabled by default, toggle with 'events' => false in config).
| Event | When |
|---|---|
Daikazu\LaravelMeta\Events\MetaCreated |
A new key is written |
Daikazu\LaravelMeta\Events\MetaUpdated |
An existing key is overwritten |
Daikazu\LaravelMeta\Events\MetaDeleted |
A key is removed (forget, flush) |
All three carry the same constructor signature:
public function __construct( public Model $model, public string $key, public mixed $oldValue, public mixed $newValue, ) {}
Example listener:
use Daikazu\LaravelMeta\Events\MetaUpdated; Event::listen(MetaUpdated::class, function (MetaUpdated $event): void { logger("Meta key [{$event->key}] changed on {$event->model->getMorphClass()} #{$event->model->getKey()}"); });
Schemas
Register a schema to attach casts and validation rules to specific keys. Schemas must be registered before the first ->meta access on that model class (e.g. in a service provider or AppServiceProvider::boot()).
Schema Field Classes
| Class | Cast applied |
|---|---|
TextMeta |
string |
NumberMeta |
float |
ToggleMeta |
boolean |
DateMeta |
datetime |
JsonMeta |
json |
All field classes extend MetaField and share these fluent methods: ->required(), ->min(int), ->max(int), ->default(mixed).
use Daikazu\LaravelMeta\Schema\NumberMeta; use Daikazu\LaravelMeta\Schema\TextMeta; use Daikazu\LaravelMeta\Schema\ToggleMeta; // In AppServiceProvider::boot() Product::metaSchema([ TextMeta::make('seo.title')->required()->max(60), NumberMeta::make('weight')->min(0), ToggleMeta::make('featured'), ]);
Validation
Call ->validate() with an array of key-value pairs to run the schema's Laravel validation rules:
// Throws Illuminate\Validation\ValidationException on failure $validated = $product->meta->validate([ 'seo.title' => 'My Product Page', 'weight' => 1.5, ]);
Only keys present in both the payload and the schema are validated; unknown keys pass through unchecked.
Computed Meta
Computed meta keys are resolved lazily from a callback and are never persisted. Register before first ->meta access.
// In AppServiceProvider::boot() Product::computedMeta([ 'profit' => fn (Product $product): int => $product->price - $product->cost, ]); // Usage $product->meta->get('profit'); // int, evaluated fresh each time $product->meta->has('profit'); // true
Setting a computed key throws InvalidArgumentException.
Indexed Metadata
For hot keys that need fast WHERE queries without JSON path overhead, configure them in indexed_keys. The package maintains a sidecar model_meta_index table with typed columns (string_value, numeric_value, boolean_value, datetime_value).
// config/meta.php 'indexed_keys' => ['featured', 'status'],
New writes to listed keys update the sidecar automatically. To backfill existing rows after adding a key to the list:
php artisan meta:index
Warning — backfill before relying on a newly indexed key. Adding a key to
indexed_keysfor a table that already has data does not retroactively populate the sidecar. Until you runphp artisan meta:index,whereMeta()on that key (which routes through the sidecar) will return incomplete results, silently omitting pre-existing rows. Always run the backfill immediately after adding a key.
Note — only
whereMeta()uses the sidecar. Equality/operator queries viawhereMeta()(andwhereMetaNot) read from the indexed sidecar table.whereMetaIn,whereMetaBetween,whereMetaNull, andorderByMetaalways read the basemodel_metatable and are unaffected by indexing.
Artisan Commands
| Command | Description |
|---|---|
meta:install |
Publish config + migrations, optionally run migrate |
meta:install --force |
Force-overwrite previously published files |
meta:cache:clear |
Flush the configured meta cache store |
meta:index |
Rebuild the indexed metadata sidecar from existing rows |
Testing
composer test
64 tests, Pest 4, PHPStan max (level 10).
Changelog
Please see CHANGELOG for recent changes.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.