thinktomorrow/dynamic-attributes
thinktomorrow/dynamic-attributes lets Laravel Eloquent models carry flexible custom attributes without schema changes. Store extra key-value data through a familiar relation, with casting and querying support, so models can be extended dynamically per record.
Install the package and verify it resolves against your Laravel/PHP version before committing to it:
composer require thinktomorrow/dynamic-attributes
Because the last release is from October 2020, check composer show thinktomorrow/dynamic-attributes and the package’s composer.json constraints first. If you are on Laravel 9/10/11, expect to test carefully or fork the trait.
Add a JSON column to the model’s table. MySQL 5.7+, MariaDB 10.2+, or Postgres jsonb are the practical targets:
Schema::table('products', function (Blueprint $table) {
$table->json('dynamic_attributes')->nullable();
});
Add the package trait to the Eloquent model. The exact property/config names should be confirmed in the package README and vendor/thinktomorrow/dynamic-attributes/src, but the shape is typically:
use Thinktomorrow\DynamicAttributes\HasDynamicAttributes;
class Product extends Model
{
use HasDynamicAttributes;
protected $dynamicAttributesColumn = 'dynamic_attributes';
// Keys that should be stored per locale, e.g. title, description
protected $localizedDynamicAttributes = ['title', 'description'];
}
A good first use case is a CMS-like entity where editors can add optional fields without a migration for every new attribute: product specs, page metadata, or block settings.
$product->color = 'red';
$product->setDynamicAttribute('title', 'Hello', 'en');
$product->setDynamicAttribute('title', 'Bonjour', 'fr');
$product->save();
$product->color; // "red"
$product->getDynamicAttribute('title', 'en'); // "Hello"
Start by reading the trait source and the config file. Small packages like this often have a few naming conventions that are easier to learn from source than from the README alone.
Keep a whitelist of allowed dynamic keys. Do not let arbitrary request keys enter the JSON column. Define allowed keys in config, a PHP enum, or a model constant, and validate against it. This prevents accidental attribute shadowing, JSON bloat, and inconsistent data.
public const DYNAMIC_KEYS = ['color', 'material', 'title', 'description'];
Use magic properties for simple reads/writes, explicit methods for localization. Magic access is convenient in Blade and resources, but explicit getDynamicAttribute($key, $locale) / setDynamicAttribute($key, $value, $locale) calls make locale intent clear.
$product->color = $request->input('dynamic.color');
$product->setDynamicAttribute('title', $request->input('dynamic.title.en'), 'en');
Validate dynamic input as a nested array. Treat the JSON column like a form group:
$data = $request->validate([
'dynamic.color' => ['nullable', 'string', 'max:50'],
'dynamic.title.en' => ['nullable', 'string', 'max:255'],
'dynamic.title.fr' => ['nullable', 'string', 'max:255'],
]);
In Blade, use old input and the model’s current values:
<input name="dynamic[color]" value="{{ old('dynamic.color', $product->color) }}">
Query JSON paths directly for simple filters. Use Laravel’s JSON query helpers:
Product::whereJsonContains('dynamic_attributes->color', 'red')->get();
Product::where('dynamic_attributes->title->en', 'Hello')->first();
For localized values, the JSON path must include the locale key. Do not assume the package’s locale fallback also applies to database queries.
Serialize dynamic values into API resources explicitly. Do not rely on Eloquent’s toArray() to expose them unless the trait appends them. Prefer an API resource:
return [
'id' => $this->id,
'color' => $this->getDynamicAttribute('color'),
'title' => $this->getDynamicAttribute('title', app()->getLocale()),
];
Wrap dynamic access in your own methods for casting. JSON preserves booleans and numbers, but form input arrives as strings. Add small accessors/mutators on the model so callers get consistent types:
public function getIsFeaturedAttribute(): bool
{
return (bool) $this->getDynamicAttribute('is_featured');
}
Use it for non-critical, flexible metadata. Good candidates: presentation options, optional specs, editor-controlled content blocks. Poor candidates: money, permissions, foreign keys, or anything you need to join, index heavily, or enforce with database constraints.
Maintenance risk is real. Ten stars and a 2020 release means you are largely on your own. Budget time to read the source, write integration tests, and possibly fork. Keep usage behind a small adapter/service class so replacing the package later is not a rewrite.
Do not double-cast the JSON column. If the trait already decodes the JSON column, adding protected $casts = ['dynamic_attributes' => 'array'] may conflict or cause unexpected behavior. Check the trait before adding casts. Inspect the raw value with $model->getRawOriginal('dynamic_attributes') when debugging.
Verify dirty tracking before trusting save(). After setting a dynamic attribute, confirm the model is dirty:
$product->color = 'red';
dump($product->isDirty()); // should be true
If the trait does not mark the underlying JSON column dirty, save() may silently skip persistence. In that case, use the package’s explicit setter or set the raw column value.
Mass assignment and security. Dynamic keys may not be fillable, and you should not make the entire JSON column fillable from request data. Whitelist keys and validate types. Also ensure a dynamic key cannot shadow a real database column or an existing Eloquent attribute.
Missing vs null. A missing dynamic key and a key explicitly set to null may behave the same through magic access. If that distinction matters, use hasDynamicAttribute($key) or inspect the decoded array.
JSON query performance. whereJsonContains and JSON path queries are convenient but often cannot use ordinary indexes. For heavy filtering, add generated columns or functional indexes in MySQL/Postgres, or promote frequently queried values to real columns.
Localization fallback is not automatic everywhere. The package may fall back to a default locale when reading, but your queries and validation rules must handle fallback explicitly. Keep locale codes consistent with your app’s configured locales.
Concurrent edits can lose data. A JSON column is saved as a whole. Two admins editing different dynamic keys at the same time can overwrite each other. For critical data, use optimistic locking, transactions, or database-level json_set updates instead of read-modify-write through the model.
Empty JSON values are a common source of bugs. Prefer storing an empty object ({}) rather than an empty array ([]) when the column represents key/value attributes. Check how the package initializes missing values and how it handles null columns.
Debugging checklist. Dump the raw JSON, check json_last_error(), confirm the column name matches the trait config, and log queries with DB::listen() to see the exact JSON paths being generated. If values do not persist, check dirty tracking first; if they do not appear, check serialization and locale resolution.
Extension points. Override getDynamicAttribute / setDynamicAttribute in your model for custom casting, keep allowed keys in a dedicated class, and use API resources to control output. Avoid scattering raw JSON path queries throughout the codebase; centralize them in repository or query-scope methods so a future package replacement is contained.
How can I help you explore Laravel packages today?