kolossal-io / laravel-multiplex
A Laravel package to attach versioned meta data to Eloquent models.
Requires
- php: ^8.2
- illuminate/contracts: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- mattiasgeniar/phpunit-query-count-assertions: ^1.1
- mockery/mockery: ^1.6
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0|^5.0
- phpstan/extension-installer: ^1.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v4.0.0
- 3.x-dev
- v3.1.0
- v3.0.3
- v3.0.2
- v3.0.1
- v3.0.0
- 2.x-dev
- v2.0.2
- v2.0.1
- v2.0.0
- 1.x-dev
- v1.8.0
- v1.7.0
- v1.6.1
- v1.6.0
- v1.5.2
- v1.5.1
- v1.5.0
- v1.4.1
- v1.4.0
- v1.3.2
- v1.3.1
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.1
- v1.1.0
- v1.0.2
- v1.0.1
- v1.0.0
- v0.10.2
- v0.10.1
- v0.10.0
- v0.9.0
- v0.8.0
- v0.7.5
- v0.7.4
- v0.7.3
- v0.7.2
- v0.7.1
- v0.7.0
- v0.6.3
- v0.6.2
- v0.6.1
- v0.6.0
- v0.5.0
- v0.4.3
- v0.4.2
- v0.4.1
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- dev-chore/drop-laravel-11
- dev-fix/meta-lookup-performance
- dev-backup/3.x-wip
This package is auto-updated.
Last update: 2026-11-04 07:51:14 UTC
README
A Laravel package to attach time-sliced meta data to Eloquent models.
What it does
Multiplex allows you to attach time-sliced metadata to Eloquent models in a convenient way.
$post = \App\Models\Post::first(); // Set meta fluently for any key – `likes` is no column of `Post`. $post->likes = 24; // Or use the `setMeta` method. $post->setMeta('likes', 24); // You may also schedule changes, for example change the meta in 2 years: $post->setMetaAt('likes', 6000, '+2 years');
Features
- Metadata is saved in versions: Schedule changes to metadata, change history or retrieve metadata for a specific point in time.
- Supports fluent syntax: Use your model’s metadata as if they were properties.
- Polymorphic relationship allows adding metadata to any Eloquent model without worrying about the database schema.
- Easy to try: Extend existing database columns of your model with versionable metadata without touching or deleting your original columns.
- Type conversion system heavily based on Laravel-Metable allows data of numerous different scalar and object types to be stored and retrieved.
Why another Metadata Package?
The main difference is that the metadata in Multiplex has a timestamp that defines validity. This allows changes to be tracked and planned. You can inspect all metadata on your model at a specific point in time and Multiplex will by default only give you the most current.
Since Multiplex is storing the metadata in a polymorphic table, it can easily be plugged into existing projects to expand properties of your models. Existing table columns can even be moved to meta records.
And it’s low profile: If you don't like it, just remove the HasMeta Trait and everything is back to normal.
Table of Contents
- Requirements
- Installation
- Attaching Metadata
- Retrieving Metadata
- Query by Metadata
- Events
- Time Traveling
- Limit Meta Keys
- Extending Database Columns
- Deleting Metadata
- Performance
- Configuration
- Enum Support
- UUID and ULID Support
Requirements
Multiplex requires PHP 8.2+ and Laravel 12+. If you are still using Laravel 11, use version 3.x.
Since Version 2 Multiplex uses SQL Window Functions (such as ROW_NUMBER() OVER (...)) for efficient and scalable queries on meta data (e.g., to determine the latest or current meta per key). This enables much better performance for large datasets compared to classic subqueries or group-by/aggregate approaches. Your database must support SQL Window Functions. This has been tested on:
- MySQL 8.0+
- PostgreSQL 12.0+
- SQLite 3.25+
Installation
You can install the package via composer:
composer require kolossal-io/laravel-multiplex
Publish the migrations to create the meta table where metadata will be stored.
php artisan migrate
Attach the HasMeta trait to any Eloquent model that needs meta attached.
use Illuminate\Database\Eloquent\Model; use Kolossal\Multiplex\HasMeta; class Post extends Model { use HasMeta; }
Attaching Metadata
By default you can use any key for attaching metadata. You can limit which keys can be used.
$model->setMeta('foo', 'bar'); // or $model->foo = 'bar';
You may also set multiple meta values by passing an array.
$model->setMeta([ 'hide' => true, 'color' => '#000', 'likes' => 24, ]);
All metadata will be stored automatically when saving your model.
$model->foo = 'bar'; $model->isMetaDirty(); // true $model->save(); $model->isMetaDirty(); // false
You can also save your model without saving metadata.
$model->saveWithoutMeta(); $model->isMetaDirty(); // true $model->saveMeta();
You can reset metadata changes that were not yet saved.
$model->resetMeta();
Metadata can be stored right away without waiting for the parent model to be saved.
// Save the given meta value right now. $model->saveMeta('foo', 123.45); // Save only specific keys of the changed meta. $model->setMeta(['color' => '#fff', 'hide' => false]); $model->saveMeta('color'); $model->isMetaDirty('hide'); // true // Save multiple meta values at once. $model->saveMeta([ 'color' => '#fff', 'hide' => true, ]);
Schedule Metadata
You can save metadata for a specific publishing date.
$user = Auth::user(); $user->saveMeta('favorite_band', 'The Mars Volta'); $user->saveMetaAt('favorite_band', 'Portishead', '+1 week'); // Changing taste in music: This will return `The Mars Volta` now but `Portishead` in a week. $user->favorite_band;
This way you can change historic data as well.
$user->saveMetaAt('favorite_band', 'Arctic Monkeys', '-5 years'); $user->saveMetaAt('favorite_band', 'Tool', '-1 year'); // This will return `Tool` – which is true since this is indeed a good band. $user->favorite_band;
You may also save multiple metadata records at once.
$user->setMeta('favorite_color', 'blue'); $user->setMeta('favorite_band', 'Jane’s Addiction'); $user->saveMetaAt('+1 week'); // or $user->saveMetaAt([ 'favorite_color' => 'blue', 'favorite_band' => 'Jane’s Addiction', ], '+1 week');
How Metadata is stored
Multiplex will store metadata in a polymorphic table and take care of serializing and unserializing datatypes for you. The underlying polymorphic meta table may look something like this:
| metable_type | metable_id | key | value | type | published_at |
|---|---|---|---|---|---|
| App\Models\Post | 1 |
color | #000 | string | 2022-11-29 13:13:45 |
| App\Models\Post | 1 |
likes | 24 | integer | 2020-01-01 00:00:00 |
| App\Models\Post | 1 |
hide | true | boolean | 2022-11-27 16:32:08 |
| App\Models\Post | 1 |
color | #fff | string | 2030-01-01 00:00:00 |
The corresponding meta values would look like this:
$post = Post::find(1); $post->color; // string(4) "#000" $post->likes; // int(24) $post->hide; // bool(true) // In the year 2030 `$post->color` will be `#fff`.
Retrieving Metadata
You can access metadata as if they were properties on your model.
$post->likes; // (int) 24 $post->color; // (string) '#000'
Or use the getMeta() method to specify a fallback value for non-existent meta.
$post->getMeta('likes', 0); // Use `0` as a fallback.
You can also retrieve the meta relation on your model. This will only retrieve the most recent value per key that is released yet.
$post->saveMeta([ 'author' => 'Anthony Kiedis', 'color' => 'black', ]); $post->saveMetaAt('author', 'Jimi Hendrix', '1970-01-01'); $post->saveMetaAt('author', 'Omar Rodriguez', '+1 year'); $post->meta->pluck('value', 'key'); /** * Illuminate\Support\Collection { * all: [ * "author" => "Anthony Kiedis", * "color" => "black", * ], * } */
There is a shorthand to pluck all the current meta data attached to the model. This will include all explicitly defined meta keys with a default of null.
// Allow any meta key and explicitly allow `foo` and `bar`. $post->metaKeys(['*', 'foo', 'bar']); $post->saveMeta('foo', 'a value'); $post->saveMeta('another', true); $post->pluckMeta(); /** * Illuminate\Support\Collection { * all: [ * "foo" => "a value", * "bar" => null, * "another" => true, * ], * } */
If you instead want to retrieve all meta that was published yet, so also include historic meta, use the publishedMeta relation.
// This array will also include `Jimi Hendrix´. $post->publishedMeta->toArray();
If you want to inspect all metadata including unpublished records, use the allMeta relation.
$post->allMeta->toArray();
If you want to inspect historic metadata including only records that are not valid anymore, use the historicMeta relation.
$post->historicMeta->toArray();
There is also a plannedMeta relation that can be used to inspect meta not yet published.
$post->plannedMeta->toArray();
You can determine if a Meta instance is the most recent published record for the related model or if it is not yet released.
$meta = $post->allMeta->first(); $meta->is_current; // (bool) $meta->is_planned; // (bool)
Please note that the is_current attribute is quite heavy, since it will first have to load the most recent meta of the corresponding model to check against.
Querying Meta Model
There are also some query scopes on the Meta model itself that may be helpful.
Meta::published()->get(); // Only current and historic meta. Meta::planned()->get(); // Only meta not yet published. Meta::publishedBefore('+1 week')->get(); // Only meta published by next week. Meta::publishedAfter('+1 week')->get(); // Only meta still unpublished in a week. Meta::current()->get(); // Only current meta without planned or historic data. Meta::history()->get(); // Only historic meta that is not valid anymore.
By default these functions will use Carbon::now() to determine what metadata is considered the most recent, but you can also pass a datetime to look from.
// Get records that have been current a month ago. Meta::current('-1 month')->get();
The current and history scopes are not available on any of the meta relations. Use the meta and historicMeta relations instead.
// This will NOT work. $model->allMeta()->current()->get(); $model->allMeta()->history()->get(); // Use the specific relations instead. $model->meta()->get(); $model->historicMeta()->get();
Query by Metadata
Querying Metadata Existence
You can query records having meta data for the given key(s).
// Find posts having at least one meta records for `color` key. Post::whereHasMeta('color')->get(); // Or pass an array to find records having meta for at least one of the given keys. Post::whereHasMeta(['color', 'background_color'])->get();
Querying Metadata Absence
You can query records not having meta data for the given key(s).
// Find posts not having any meta records for `color` key. Post::whereDoesntHaveMeta('color')->get(); // Or find records not having meta for any of the given keys. Post::whereDoesntHaveMeta(['color', 'background_color'])->get();
Querying Metadata by Value
You can retrieve models having meta with the given key and value.
// Find posts where the current attached color is `black`. Post::whereMeta('color', 'black')->get(); // Find posts where the current attached color is not `black`. Post::whereMeta('color', '!=', 'black')->get(); // Find posts that are `visible`. Post::whereMeta('visible', true)->get(); // There are alternatives for building `or` clauses for all scopes. Post::whereMeta('visible', true)->orWhere('hidden', false)->get();
Multiplex will take care of finding the right datatype for the passed query.
// Matches only meta records with type `boolean`. Post::whereMeta('hidden', false)->get(); // Matches only meta records with type `datetime`. Post::whereMeta('release_at', '<=', Carbon::now())->get();
You may also query by an array if values. Each array value will be typecasted individually.
// Find posts where `color` is `black` (string) or `false` (boolean). Post::whereMetaIn('color', ['black', false])->get();
If you would like to query without typecasting use whereRawMeta() instead.
Post::whereRawMeta('hidden', '')->get(); Post::whereRawMeta('likes', '>', '100')->get();
You can also define which datatype to use.
Post::whereMetaOfType('integer', 'count', '0')->get(); Post::whereMetaOfType('null', 'foo', '')->get();
Querying empty or non-empty Metadata
You can query for empty or non-empty metadata where null or empty strings would be considered being empty.
Post::whereMetaEmpty('favorite_band')->get(); // Get all posts having meta names `likes` and `comments` where *both* of them are not empty. Post::whereMetaNotEmpty(['likes', 'comments'])->get();
Events
You can listen for the following events that will be fired by Multiplex.
MetaHasBeenAdded
This event will be fired once a new version of meta is saved to the model.
use Kolossal\Multiplex\Events\MetaHasBeenAdded; class SomeListener { public function handle(MetaHasBeenAdded $event) { $event->meta; // The Meta model that was added. $event->model; // The parent model, same as $event->meta->metable. $event->type; // The class name of the parent model. } }
MetaHasBeenRemoved
This event will be fired once metadata is removed by using deleteMeta. The event will fire only once per key and the $meta property on the event will contain the latest meta only.
use Kolossal\Multiplex\Events\MetaHasBeenRemoved; class SomeListener { public function handle(MetaHasBeenRemoved $event) { $event->meta; // The Meta model that was removed. $event->model; // The parent model, same as $event->meta->metable. $event->type; // The class name of the parent model. } }
Time Traveling
You can get the metadata for a model at a specific point in time.
$user = Auth::user()->withMetaAt('-1 week'); $user->favorite_band; // Tool $user->withMetaAt(Carbon::now())->favorite_band; // The Mars Volta
This way you can inspect the whole set of metadata that was valid at the time.
Post::first()->withMetaAt('2022-10-01 15:00:00')->meta->pluck('value', 'key');
You can also query by meta for a specific point in time.
Post::travelTo(Carbon::now()->subWeeks(2))->whereMetaIn('foo', [false, 0])->get(); Post::travelTo(Carbon::now()->addYears(2))->where('category', 'tech')->get();
Remember to travel back if you want to perform further actions.
Post::travelTo(Carbon::now()->subYear())->where('category', 'tech')->get(); Post::where('category', 'tech')->get(); // Will still look for meta published last year. Post::travelBack(); Post::where('category', 'tech')->get(); // Find current meta.
Limit Meta Keys
You can limit which keys can be used for metadata by setting $metaKeys on the model.
class Post extends Model { use HasMeta; protected array $metaKeys = [ 'color', 'hide', ]; }
By default all keys are allowed.
protected array $metaKeys = ['*'];
You can also change the allowed meta keys dynamically.
$model->metaKeys(['color', 'hide']);
You might as well cast your attributes using the MetaAttribute cast which will automatically allow the attribute being used as a meta key.
use Kolossal\Multiplex\MetaAttribute; class Post extends Model { use HasMeta; protected $metaKeys = []; protected $casts = [ 'body' => MetaAttribute::class, ]; }
Trying to assign a value to a meta key that is not allowed will throw a Kolossal\Multiplex\Exceptions\MetaException.
If you have Eloquent Strictness enabled it is recommended to explicitely cast the meta attributes to MetaAttribute.
Typecast Meta Keys
Sometimes you may wish to force typecasting of meta attributes. You can bypass guessing the correct type and define which type should be used for specific meta keys.
protected array $metaKeys = [ 'foo', 'count' => 'integer', 'color' => 'string', 'hide' => 'boolean', ];
Extending Database Columns
By default Multiplex will not touch columns of your model. But sometimes it might be useful to handle an existing table column with Multiplex instead, e.g. to track its changes over time.
Consider having an existing Post model with only a title and a body column. By explicitly adding body to our array of meta keys body will be handled by Multiplex from now on – the body column of the posts table is neither read nor written anymore.
class Post extends Model { use HasMeta; protected $metaKeys = [ '*', 'body', ]; }
\DB::table('posts')->insert(['title' => 'A title', 'body' => 'A body.']); $post = Post::first(); $post->body; // null $post->body = 'This. Is. Meta.'; $post->save(); $post->body; // This. Is. Meta.
Multiplex will remove the original column from the model’s attributes when retrieving models from the database so you don’t get stale data. To keep the values of the column, move them to meta records first.
Moving Columns to Meta
To move the values of existing columns to meta records entirely, e.g. before dropping the columns, use the MetaBackfill helper inside a migration:
use App\Models\Post; use Illuminate\Database\Migrations\Migration; use Kolossal\Multiplex\Support\MetaBackfill; return new class extends Migration { public function up(): void { MetaBackfill::fromColumns(Post::class, ['body', 'color'])->run(); } };
For every row a meta record is created per non-null column, published at the time the row was created (created_at), so newer meta stays current and the column value becomes part of the history. Column values of keys typecast in $metaKeys are converted to that type.
MetaBackfill::fromColumns(Post::class, ['body']) ->publishedAt('updated_at') // Take the time from another column… ->publishedAt(now()->startOfYear()) // …or a fixed time (`null` means now). ->chunk(1000) // Rows to process at once, defaults to 500. ->dryRun() // Only count the records that would be created. ->run(); // Returns the number of created meta records.
The table rows are read without global scopes, so soft deleted rows are included. Meta records are inserted in bulk without dispatching any events. Running the helper again will not create duplicates for the same model, key and publishing time. The columns themselves are left untouched, so drop them afterwards if you no longer need them.
Deleting Metadata
You can delete any metadata associated with the model from the database.
// Delete all meta records for the `color` key. $post->deleteMeta('color'); // Or delete all meta records associated with the model. $post->purgeMeta();
Performance
Since Multiplex stores metadata in a polymorphic One To Many relationship querying your models could easily result in a N+1 query problem.
Depending on your use case you should consider eager loading the meta relation, for example using $with on your model. This might be especially useful if you are extending database columns.
// Worst case: 26 queries if `color` is a meta value. $colors = Post::take(25)->get()->map( fn ($post) => $post->color; ); // Same result with only 2 queries. $colors = Post::with('meta')->take(25)->get()->map( fn ($post) => $post->color; );
Configuration
There is no need to configure anything but if you like, you can publish the config file with:
php artisan vendor:publish --tag="multiplex-config"
Enum Support
Multiplex supports backed enumerations introduced in PHP 8.1 whereas basic enumerations would not work.
enum SampleEnum: string { case Hearts = 'hearts'; case Diamonds = 'diamonds'; } $model->saveMeta('some_key', SampleEnum::Diamonds); // true $model->some_key === SampleEnum::Diamonds;
UUID and ULID Support
If your application uses UUIDs or ULIDs for the model(s) using metadata, you may set the multiplex.morph_type setting to uuid or ulid before running the migrations. You might as well set the MULTIPLEX_MORPH_TYPE environment variable instead, if you don’t want to publish the configuration file.
This will ensure Meta models will use UUID/ULID and that proper keys and foreign keys are used when running the migrations.
Credits
This package is heavily based on and inspired by Laravel-Metable by Sean Fraser as well as laravel-meta by Kodeine. The Package Skeleton by the great Spatie was used as a starting point.
License
Copyright © kolossal. Released under MIT License.