daikazu/laravel-meta

This is my package laravel-meta

Maintainers

Package info

github.com/daikazu/laravel-meta

pkg:composer/daikazu/laravel-meta

Fund package maintenance!

Daikazu

Statistics

Installs: 2

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-06-06 21:11 UTC

This package is auto-updated.

Last update: 2026-06-06 21:28:31 UTC


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.

Latest Version on Packagist GitHub Tests Action Status Total Downloads

Typed, casted, queryable key-value metadata for any Eloquent model.

  • One $model->meta property 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_meta table (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-configconfig/meta.php
  • laravel-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

  1. protected string $metaDriver = '...' on the model — explicit override.
  2. protected string $metaColumn = '...' on the model — implies json driver.
  3. meta.driver config 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_keys for a table that already has data does not retroactively populate the sidecar. Until you run php 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 via whereMeta() (and whereMetaNot) read from the indexed sidecar table. whereMetaIn, whereMetaBetween, whereMetaNull, and orderByMeta always read the base model_meta table 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.