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.
Install the package and its migration, then add the trait to whichever model needs settings:
composer require zachleigh/laravel-property-bag
php artisan vendor:publish --provider="Zachleigh\PropertyBag\PropertyBagServiceProvider" --tag=migrations
php artisan migrate
That creates a single polymorphic property_bags table, so any number of models can carry settings without a schema change per model.
use Zachleigh\PropertyBag\Traits\HasPropertyBag;
class User extends Model
{
use HasPropertyBag;
}
Where to look first: the trait itself (vendor/zachleigh/laravel-property-bag/src/Traits/HasPropertyBag.php) and the published migration. The package is small — the trait is the entire public API surface, and reading it takes five minutes. Confirm the exact method names and column names from the source rather than trusting blog snippets, because the README has drifted from the code in places.
First use case: per-user UI preferences — theme, locale, timezone, notification toggles — where you do not want four nullable columns and a migration every time product adds a switch.
$user->addProperty('theme', 'dark');
$user->addProperty('notifications.email', false);
$user->getProperty('theme'); // 'dark'
$user->hasProperty('notifications.email'); // true
$user->removeProperty('theme');
Wire it up behind a typed accessor immediately rather than calling getProperty() from controllers:
class UserSettings
{
public function __construct(private User $user) {}
public function theme(): string
{
return $this->user->getProperty('theme') ?: 'light';
}
public function emailNotifications(): bool
{
return filter_var($this->user->getProperty('notifications.email'), FILTER_VALIDATE_BOOLEAN);
}
}
Namespace your keys. The bag is a flat key/value store with no grouping enforcement. Adopt dotted keys (ui.theme, billing.currency, notifications.digest) from day one and every settings screen, export, and bulk update stays tractable. It also lets you prefix-scan if you later need to read a whole group.
Centralize defaults in one class, not in call sites. getProperty() returns null for missing keys, so defaults scattered across views become a maintenance problem.
class SettingsSchema
{
public const DEFAULTS = [
'ui.theme' => 'light',
'ui.locale' => 'en',
'notifications.email' => true,
'notifications.digest' => 'weekly',
];
public const VALIDATION = [
'ui.theme' => 'in:light,dark',
'ui.locale' => 'in:en,fr,de',
'notifications.email' => 'boolean',
];
}
Persist from a form request in a single transaction. Settings screens almost always submit several fields; a partial write leaves the bag inconsistent.
public function update(SettingsRequest $request, User $user)
{
DB::transaction(function () use ($request, $user) {
foreach ($request->validated() as $key => $value) {
$user->hasProperty($key)
? $user->updateProperty($key, $value)
: $user->addProperty($key, $value);
}
});
}
Eager load the relation on listing screens. The bag is a morph relation; rendering a table of 50 users that each call getProperty() produces 50 extra queries.
$users = User::with('propertyBag')->paginate(50);
Wrap it in a settings service, not a trait call per controller. Injecting UserSettings gives you one place to add caching, casting, and later a swap to a JSON column when you retire the package.
Bulk-edit is the killer feature. Because the table is polymorphic, admin tooling that reads/writes properties works for User, Team, Project alike. If you build that tooling, key it off a schema class (above) rather than a hardcoded list in Blade.
illuminate/* constraint in composer.json before assuming it installs on Laravel 10/11. On modern Laravel you almost certainly need a fork, a replace with a local path repository, or a straight replacement.$table->json('settings')->nullable() plus protected $casts = ['settings' => 'array']; on the model reproduces 90% of the value with zero dependencies. Keep the getProperty/setProperty method names in a trait so call sites survive the migration unchanged.'0'/'1' will not survive a strict === true check — cast explicitly in your accessor layer.setProperty('ui.them', ...) silently creates a new key rather than failing. Validate against your schema class before writing.DB::transaction with lockForUpdate() on the bag row for anything user-facing and concurrent.deleting observer that deletes $model->propertyBag (or a scheduled cleanup).getProperty() hits the relation. Cache the entire bag per model in the request (or in Redis keyed by model+id), invalidate on write.refresh() after writes. Other loaded instances of the same model keep the stale relation; re-fetch before rendering a confirmation.vendor:publish --tag=migrations finds nothing, the tag name differs by version — publish with --provider and no tag to dump everything, then prune.setUp() so feature tests are not asserting against nulls. Use RefreshDatabase.propertyBag() relation and the trait's protected persistence methods. If you need audit history, override the write method in your own trait rather than forking the package — it keeps the upgrade path alive if the package is ever un-archived.How can I help you explore Laravel packages today?