zachleigh/laravel-property-bag
Add a flexible property bag to Laravel Eloquent models: store arbitrary key-value data as JSON without extra tables or migrations, with a clean API for setting, getting, and managing custom properties.
What it is. zachleigh/laravel-property-bag attaches an arbitrary key/value "property bag" to any Eloquent model via a trait and a single polymorphic side table. It replaces per-entity schema churn (a settings column or a dozen nullable columns) with runtime get/set-style helpers. The core idea is sound and well understood; the artifact is not.
Architecture fit. Good fit for sparse, heterogeneous, read-mostly per-entity configuration — per-tenant feature toggles, per-user notification preferences, per-integration endpoints. Explicitly poor fit for anything you must WHERE, ORDER BY, JOIN, or aggregate on: a key/value side table turns every such query into a join plus string comparison, and defeats indexes. Architecturally the package sits in an awkward middle layer between deploy-scoped config (config/) and first-class domain columns, and that middle layer is exactly where unowned, untyped data accumulates. The realistic failure mode of this pattern is not technical, it is governance: a settings bag with no key registry becomes a shadow schema within two quarters. Any adoption decision must pair the package with a documented key registry and owner per key.
Integration feasibility. Low as-is. The repository is archived with a final release in January 2020, which places it in the Laravel 5.5–6.x era. Its Composer constraints on illuminate/* will almost certainly refuse installation on Laravel 9/10/11, and it has not been exercised against PHP 8.x. Therefore composer require is not an integration path; fork-and-vendor or reimplement is the integration path. Mitigating factor: the surface area is small (a trait, a couple of value/collection classes, one migration), so the cost of taking ownership is measured in days, not weeks. The real cost is perpetual ownership of a dependency that will never receive another upstream commit.
Technical risk.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Abandoned upstream; no security or framework-compat patches | Certain | Medium | Fork under org namespace, pin, add to dependency review queue |
| Composer/API breakage on Laravel 10/11 upgrade | High | Medium | Vendor the code; treat as first-party |
| N+1 queries (settings lazy-loaded per model in collections) | High | Medium–High | Eager-load the relationship by default on consuming models; add request-scoped cache |
| Unbounded table growth / shadow schema | Medium | Medium | Key registry, TTL/ownership metadata, periodic audit query |
| Type coercion drift (bool/int/null vs string storage) | Medium | Medium | Enforce cast metadata per key; contract tests |
Settings leaking into API responses via toArray()/$appends |
Medium | High | Explicitly hide the relationship; exclude from serialization |
| Orphan rows on model delete | Medium | Low | FK/cascade or scheduled cleanup |
| Multi-key writes not atomic | Medium | Medium | Wrap in transactions at call sites |
Key questions for engineering.
beta_x = true"), or only load settings for a known model? Only the latter justifies this pattern.settings table in the schema we would be duplicating?json column casting plus a typed accessor cannot satisfy?Stack fit. The package is idiomatic Laravel — service provider, trait, Eloquent relationship, migration stub — so it will "feel" native to the team. That is a genuine ergonomics plus and a genuine risk: it lowers the barrier to writing settings that should have been columns.
Options considered.
| Option | Effort | Fit | Verdict |
|---|---|---|---|
| A. Adopt as-is | 0.5d | Only on legacy Laravel 6 | Reject — locks the app to an EOL framework |
| B. Fork, bump constraints, vendor | 2–3d + ongoing | High if polymorphic single table is genuinely needed | Viable |
C. Reimplement as ~150 LOC trait over a native json column |
2–3d | High for model-scoped settings; no cross-model queries | Recommended default |
D. spatie/laravel-schemaless-attributes |
1d | Actively maintained, same schema-less idea | Preferred if a maintained dependency is acceptable |
| E. Bespoke typed settings table with a key registry | 4–5d | Highest control | Choose if settings are business-critical |
Recommendation. Do not adopt this package as a dependency. The opportunity score of 0.27 is consistent with what the code shows: the concept is commoditized (a JSON column, or a maintained alternative, covers it) and the artifact is unmaintained. Use the repository as a reference implementation — read its trait, migration, and type-casting logic, then either reimplement it (Option C) or take the maintained alternative (Option D). Reserve Option B for the narrow case where the team specifically needs the polymorphic side-table shape and wants to preserve the existing API verbatim.
Compatibility. Assume untested on Laravel 7+ and unsupported on PHP 8.1+ until proven otherwise. If forking, the work is: widen illuminate/* and php constraints, run the package's tests on the current matrix, fix dynamic-property and null-to-string deprecations, and verify the migration stub matches current Blueprint syntax. Budget a half-day for the test matrix alone.
Sequencing.
settings table size alert.Maintenance. Zero upstream. The MIT license permits a fork, but from day one we absorb framework-compat work, PHP deprecation fixes, and any security issue found in code we did not write. Add it to the dependency register with an explicit "vendored, no upstream" flag and a named owner — an unowned vendored package is the classic path to a surprise during a framework upgrade.
Support. No vendor, no maintainer, and community answers will increasingly reference Laravel 5/6 idioms that no longer apply. The supportable position is: the code is ours, the runbook is ours, and the API surface is frozen to what we use. Keep the vendored code minimal — strip unused casting/collection features rather than carrying them.
Scaling. Row count is models × settings-per-model. At 100k models with 8 settings each, that is 800k rows — fine with a unique index on (model_type, model_id, name), but bad without it. Two scaling hazards dominate: N+1 reads when rendering collections, and write amplification when a single action touches many models. Mitigations: always eager-load, add a request-scoped memoization layer, and if read volume justifies it, a Redis cache keyed by model with invalidation on saved/deleted model events. Note that the caching layer must handle multi-node invalidation correctly — stale settings are a subtle, hard-to-reproduce bug class.
Failure modes. Silent null on a missing key rather than a loud error (prefer explicit defaults at every call site); type drift when a key's stored representation changes and old rows are not migrated; key collisions across teams sharing one bag; orphan rows after model deletion; partial writes on multi-key updates outside a transaction; accidental serialization of settings into API payloads; and the quiet one — settings that were once load-bearing become unread, with nobody able to prove they are safe to delete.
Ramp-up. Technically trivial: a competent Laravel engineer is productive in under half a day. Organizationally expensive: the discipline of when not to use it and the key-registry habit take deliberate enforcement. Budget a short design-review checklist item — "new setting: column, config, or bag?" — or the bag will absorb fields that belong in the schema, and the cost of reversing that later is a data migration, not a code change.
How can I help you explore Laravel packages today?