Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Multiplex Laravel Package

kolossal-io/laravel-multiplex

Multiplex attaches time-sliced, versioned metadata to Eloquent models. Set meta fluently as properties, schedule future changes, retrieve history or values at a specific time, and use a polymorphic relationship to add metadata to any model without schema changes.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Verify the platform before anything else. Multiplex 2.x requires Laravel 11+, PHP 8.2+, and a database with SQL window functions: MySQL 8.0+, PostgreSQL 12+, or SQLite 3.25+. The window-function dependency is not checked at install time — it fails at query time when resolving "current" meta. If you are on MariaDB, MySQL 5.7, or an older managed Postgres, stop here and use plank/laravel-metable instead.

2. Install and migrate.

composer require kolossal-io/laravel-multiplex
php artisan migrate

The service provider auto-loads the meta table migration. Publish it only if you need to change the table name or add indexes:

php artisan vendor:publish --tag="multiplex-migrations"
php artisan vendor:publish --tag="multiplex-config"

3. Attach the trait to any model that needs extensible, time-sliced attributes:

use Kolossal\Multiplex\HasMeta;

class Post extends Model
{
    use HasMeta;
}

4. Where to look first. In order: config/multiplex.php (table name, custom Meta model, key allow-list, type casters), the published migration (indexes and column types), then the README sections How Metadata is stored and Performance. The stored row shape — (metable_type, metable_id, key, value, type, published_at) — tells you more about how this package behaves than any other doc.

5. First use case — extend a table without a migration. The canonical day-one win is adding editorial/product attributes to an existing model without schema changes:

$post = Post::first();

$post->reading_time = 7;                 // fluent
$post->setMeta('hide_from_feed', true);  // explicit
$post->setMetaAt('featured_until', now()->addWeek(), '+1 week');

$post->save();                           // meta is persisted with the model

$post->reading_time;                     // int(7), cast from the `type` column
$post->getMeta('subtitle', '');          // fallback for missing keys

Smoke-test in tinker before building features: set a value, save(), refresh(), read it back, then $post->allMeta to see the actual rows. If that round-trips, everything else is configuration.

Implementation Patterns

Respect the model save lifecycle. Meta is dirty until the parent model is saved. Know the four escape hatches and use them deliberately:

$post->foo = 'bar';
$post->isMetaDirty();        // true
$post->saveWithoutMeta();    // persist model only
$post->saveMeta();           // persist pending meta later
$post->resetMeta();          // discard unsaved meta changes

$post->saveMeta('foo', 123); // write one key immediately, no model save

saveMeta() with no arguments flushes all dirty keys; with a string or array it flushes only those. Use the surgical form inside jobs and webhooks where you do not want to touch updated_at on the parent.

Use scheduled meta for anything with an effective date. This is the package's differentiator, so lean on it instead of adding published_at columns:

$product->saveMetaAt('price', 2499, '+30 days');
$product->saveMetaAt('headline', 'Spring Sale', '2026-03-01 00:00:00');

$product->price;   // today's price; automatically the new one after the date

Retroactive writes (saveMetaAt('tier', 'gold', '-2 years')) let you backfill history so that Meta::current('-1 month') reports what was actually true then. Wire this into seeders for demo data rather than hardcoding timestamps.

Pick the right relation, not allMeta. Four relations exist and they are not interchangeable:

Relation Returns
meta current published value per key — use this for display and eager loading
publishedMeta current + historic rows
historicMeta rows superseded by a newer version
plannedMeta rows not yet released
allMeta literally everything

For API responses, prefer pluckMeta(), which collapses to a flat key→value collection and includes explicitly declared keys with a null default:

$post->metaKeys(['*', 'reading_time', 'subtitle']);
$post->pluckMeta(); // ['reading_time' => 7, 'subtitle' => null, ...]

Eager load aggressively. Every magic property access on a fresh model triggers a lazy load of the meta relation. A 50-row index page reading $post->subtitle produces 50 queries. Always:

Post::with('meta')->paginate(50);

Then pluckMeta(), getMeta(), and property access all resolve from the loaded collection.

Query through the scopes rather than joining by hand. They handle the window-function subquery and typecasting for you:

Post::whereHasMeta('subtitle')->get();
Post::whereDoesntHaveMeta(['subtitle', 'reading_time'])->get();

Post::whereMeta('visible', true)->get();              // bool type matched
Post::whereMeta('release_at', '<=', now())->get();    // datetime type matched
Post::whereMetaIn('color', ['black', false])->get();  // per-element casting
Post::whereMetaEmpty('subtitle')->get();

// Escape hatches when casting gets in the way:
Post::whereRawMeta('likes', '>', '100')->get();
Post::whereMetaOfType('integer', 'count', '0')->get();

Or-clauses follow the usual Laravel convention: ->whereMeta(...)->orWhereMeta(...). When combining with whereHas on real relationships, group them in a closure or you will leak OR precedence.

Treat meta like a form-backed resource. Keep mass assignment out of it; build an explicit write path in a form request or action:

$data = $request->validated();

$post->setMeta(collect($data)->only(['subtitle', 'reading_time', 'hero_color'])->all());
$post->save();

Constrain the surface area with metaKeys() (declarative defaults) or the allow-list in config/multiplex.php — without it, any key is writable, which is a subtle injection vector if you ever pass request data straight through.

Exploit the column fallback for incremental migration. If posts already has a subtitle column, Multiplex reads the meta value first and falls back to the column. That means you can migrate a column to meta incrementally: write to meta, keep the column as a safety net, backfill old rows with saveMetaAt(..., $row->created_at), then drop the column months later. Rolling back is just removing the trait.

Handle enums and identifiers explicitly. Type casting covers scalars, arrays, collections, JSON, datetimes, and null, and enums are supported — but the enum's backing value is what lands in the value column. If you query by an enum later, pass the enum instance and let the caster serialize it, or query with whereRawMeta() against the backing value. For UUID/ULID-keyed models, verify the morph column type in the published migration; a bigint metable_id will silently fail to match string keys.

Write tests at the meta layer, not just the HTTP layer. Factories that call setMeta() before save() are the cheapest way to fixture state, and assertDatabaseHas('meta', [...]) catches the type-casting regressions that bite hardest (a '24' stored as string rather than integer will not match whereMeta('likes', 24)).

Gotchas and Tips

Bulk writes bypass Multiplex entirely. Post::where(...)->update([...]), upsert(), and raw SQL do not fire the trait's save hooks, and meta columns are invisible to them anyway. Any mass update touching a meta-backed field must iterate models or explicitly call saveMeta().

is_current is expensive. It re-queries the latest meta for the model to compare against. Never put it inside a loop over allMeta, and never use it in a Blade partial rendered per row. If you need "is this the current version" at scale, compute it in the query with Meta::current() instead.

current() and history() are not available on relations. $model->allMeta()->current() will not work. Use $model->meta() and $model->historicMeta() — the relations already encode that filtering. This is the single most common confusion reported against the package.

Type casting is strict in queries, lossy in storage. whereMeta('hidden', 'false') will not match a boolean false; whereMeta('likes', '24') may not match an integer 24. Decide per key whether it is a string, and be consistent in both writers and readers. When in doubt, whereRawMeta() plus an explicit whereMetaOfType() is more predictable than guessing.

Same-timestamp writes stack up. Every saveMeta() call inserts a version, even if the value is unchanged and the published_at is identical. On a hot path (e.g. a "last viewed" key updated per request) this grows the table quickly. Either throttle writes at the application level or schedule a pruning job that deletes historicMeta older than N days.

Timezone and boundary conditions. published_at comparisons rely on the app's configured timezone (usually UTC). A value scheduled for '+1 week' and a Carbon::now() evaluated a second earlier can disagree across a queued job versus a web request. When exactness matters, pass an explicit datetime rather than a relative string, and store dates in UTC consistently.

JSON serialization is not automatic. Meta keys do not appear in toArray()/toJson() unless you explicitly merge them. Do it in an API resource:

return [
    'id' => $this->id,
    'title' => $this->title,
    ...$this->pluckMeta()->all(),
];

Also audit $hidden/$appends — meta keys living as magic properties are easy to leak into an unexpected payload.

Config caching after publish. config/multiplex.php is read at boot. After changing the table name, custom Meta model, or key allow-list, run php artisan config:clear — otherwise long-lived queue workers keep the old registry and type casters.

Extension points worth knowing: subclass Kolossal\Multiplex\Meta and point config('multiplex.meta_model') at it to add your own scopes or casts; register additional type casters in config if you store domain objects; and listen to the package's model events (see the Events section) if you need to react to meta changes — for example, busting a cache or writing an audit log entry. Because the trait is additive and reversible, adding and then removing HasMeta is a legitimate rollback strategy: no data loss to the original columns, just an orphaned meta table you can truncate.

Debugging playbook. When a value "doesn't stick": (1) $model->isMetaDirty($key), (2) $model->allMeta to see whether a row exists at all, (3) $model->saveMeta() to rule out the lifecycle, (4) Meta::current()->where('key', $key)->toSql() with bindings if a query is the problem. Nine times out of ten it is a missing save(), an over-eager saveWithoutMeta(), or a type mismatch between writer and query.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource