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 Property Bag Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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);
    }
}

Implementation Patterns

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.

Gotchas and Tips

  • Archived since January 2020. Check the 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.
  • Replacement is one column. The package is essentially a polymorphic JSON store. $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.
  • No type casting. Values come back as stored (strings, or decoded JSON if the column is JSON). Booleans stored as '0'/'1' will not survive a strict === true check — cast explicitly in your accessor layer.
  • No key validation. A typo in setProperty('ui.them', ...) silently creates a new key rather than failing. Validate against your schema class before writing.
  • Read-modify-write races. If the bag is one row per model, concurrent requests updating different keys can clobber each other. Use DB::transaction with lockForUpdate() on the bag row for anything user-facing and concurrent.
  • Polymorphic rows are not cleaned up. No foreign key exists across a morph, so deleting a model orphans its bag row. Add a deleting observer that deletes $model->propertyBag (or a scheduled cleanup).
  • Nothing is cached. Every 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.
  • Migration publishing quirk. If vendor:publish --tag=migrations finds nothing, the tag name differs by version — publish with --provider and no tag to dump everything, then prune.
  • Testing. There are no factories for the bag; build a helper that seeds defaults for a model in setUp() so feature tests are not asserting against nulls. Use RefreshDatabase.
  • Extension points worth knowing: the 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.
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.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox