Typed, dynamic data fields for Eloquent models. Attach admin-defined fields
to any model and read them as typed PHP values — booleans cast to bool,
numbers to float, dates to Carbon, files to your File model, and so on.
Two parallel storage modes:
- Cast mode — one JSON column on the owner model holds a self-describing
DataFielddocument (schema + values together). Ergonomic typed object access, atomic per-column writes, multiple "forms" per model. - Row mode — one row per field in a polymorphic
data_fieldstable. Cross-row queries by key/value, per-field granular updates, indexable.
Pick whichever fits the column you're working with — a single model can use both modes on different attributes.
use Ssntpl\DataFields\Support\DataField;
class User extends Model
{
protected $casts = [
'preferences' => DataField::class, // cast mode
];
use \Ssntpl\DataFields\Concerns\HasDataFields; // row mode
}
// Cast mode — work with the column as a typed document
$user->preferences = DataField::section(items: [
['key' => 'dark_mode', 'type' => 'bool', 'value' => true],
['key' => 'language', 'type' => 'text', 'value' => 'en'],
]);
$user->preferences->dark_mode->value; // bool true
$user->preferences->dark_mode->value = false;
$user->save();
// Row mode — store fields polymorphically
$user->fields()->create([
'key' => 'phone', 'type' => 'text', 'value' => '+91-9999900000',
]);
$user->getFieldValue('phone'); // '+91-9999900000'- Installation
- Choosing a mode
- Cast mode
- Row mode
- Field types reference
- File and files types
- Configuration
- API reference
- Common patterns
- Migrating from 0.2.x
- Testing
- Security
- Changelog
- Contributing
- Credits
- License
Install via Composer:
composer require ssntpl/data-fieldsPublish the config (optional — only needed if you want to override defaults):
php artisan vendor:publish --tag=data-fields-configIf you're using row mode (the data_fields table), publish and run the
migration:
php artisan vendor:publish --tag=data-fields-migrations
php artisan migrateIf you'd rather skip the publish step, set data-fields.auto_load_migrations
to true in the published config — the service provider will load the
package's migration directly.
Cast mode doesn't ship a table — the consumer adds a JSON column to whichever model they want:
Schema::table('users', function (Blueprint $table) {
$table->json('preferences')->nullable();
});- PHP 8.2+
- Laravel 11.0 or 12.0
ssntpl/laravel-files^0.1 (required — used by thefile/filesfield types)
Both modes share the same conceptual shape (key, type, value, label, description, validations, meta) and the same casting layer. They differ in where the data lives and which read/write patterns they optimise for.
| Concern | Cast mode | Row mode |
|---|---|---|
| Storage | One JSON column on the owner model | One row per field in data_fields |
| Read one field | Single column read | One DB query (or via eager load) |
| Read all fields | Single column read | One query (eager-loadable) |
| Update one field | Whole column rewrite | One row update |
| Query across rows by field | Hard (DB-specific JSON path queries) | Native SQL |
| Multiple distinct "forms" per record | Natural (one column per form) | Needs a discriminator column |
Containers (step / section / group) |
First-class | Not supported |
| Concurrent writes to different fields | Last-write-wins on the column | Field-independent |
| DB-level constraints (FK, unique) | None inside JSON | Native SQL |
| External (non-PHP) consumers | Must understand JSON shape | Trivial — normalised rows |
| Best fit | Settings, preferences, structured submissions | EAV, searchable attributes, sparse data |
Rule of thumb: start with cast mode. Reach for row mode when you have a real need for cross-row queries by field value, BI/reporting tooling, or field-level DB constraints.
A single JSON column on the owner model holds the entire field document.
The Laravel cast hydrates that JSON into a DataField object you can read,
mutate, and persist with ordinary $model->save() semantics.
-
Add a
jsoncolumn to your model's table:Schema::table('users', function (Blueprint $table) { $table->json('preferences')->nullable(); });
-
Add the cast to your model — write
DataField::classdirectly; the package resolves to its internal cast via Laravel'sCastableinterface:use Ssntpl\DataFields\Support\DataField; class User extends Model { protected $casts = [ 'preferences' => DataField::class, ]; protected $fillable = ['preferences', /* ... */]; }
That's it. Each cast column can hold an entire form's worth of fields. Attach as many as you need:
protected $casts = [
'preferences' => DataField::class,
'email_settings' => DataField::class,
'shipping_defaults' => DataField::class,
];A column casts to a single DataField object. The simplest form is a
container holding leaf fields:
use Ssntpl\DataFields\Support\DataField;
$user->preferences = DataField::section(items: [
['key' => 'dark_mode', 'type' => 'bool', 'value' => true],
['key' => 'language', 'type' => 'text', 'value' => 'en'],
]);
$user->save();You can also assign a plain array — the cast coerces it to a DataField for
you:
$user->preferences = [
'type' => 'section',
'items' => [
['key' => 'dark_mode', 'type' => 'bool', 'value' => true],
],
];
$user->save();A null column casts to null. Assigning null clears the column:
$user->preferences = null;
$user->save();There's no implicit default — initialise the document explicitly via a factory or by assignment. This matches Laravel's nullable-cast contract.
Property access returns the matching DataField; chain ->value to read
the typed value:
$user->preferences->dark_mode; // DataField (leaf)
$user->preferences->dark_mode->value; // bool true (cast via the field's type)
$user->preferences->language->value; // 'en'For nested structures, chain through container children:
$user->preferences->appearance->dark_mode->value;Or use the explicit dotted-path lookup:
$user->preferences->dataField('appearance.dark_mode')->value;If a key doesn't exist, property access returns null:
$user->preferences->missing_key; // nullThe DataField object is mutable. Mutations persist when you call
$model->save():
$user->preferences->dark_mode->value = false;
$user->save();Dirty tracking works through Laravel's standard cast-re-serialisation:
isDirty('preferences') returns true after any in-memory change, and
save() writes the new JSON when it differs from the original.
You can also replace an entire field by assignment:
$user->preferences->dark_mode = DataField::leaf('bool', false, ['key' => 'dark_mode']);
// or, equivalently, with a plain array:
$user->preferences->dark_mode = ['key' => 'dark_mode', 'type' => 'bool', 'value' => false];
$user->save();Containers support addField and removeField:
$user->preferences->addField([
'key' => 'fontsize', 'type' => 'number', 'value' => 14,
]);
$user->preferences->removeField('language');
$user->save();Adding a duplicate sibling key throws \InvalidArgumentException immediately
— structural validation runs at the point of authorship, not at save.
Three container types are available — they're semantically equivalent inside the package; pick whichever your UI vocabulary prefers:
| Type | Typical use |
|---|---|
section |
Logical grouping of related fields |
step |
A wizard/multi-step form pane |
group |
An inline cluster, smaller than a section |
Containers nest arbitrarily:
$user->preferences = DataField::section(items: [
[
'type' => 'group', 'key' => 'appearance', 'label' => 'Appearance',
'items' => [
['key' => 'dark_mode', 'type' => 'bool', 'value' => true],
['key' => 'accent', 'type' => 'select_single', 'value' => 'blue',
'options' => [['key' => 'blue'], ['key' => 'red']]],
],
],
[
'type' => 'group', 'key' => 'notifications', 'label' => 'Notifications',
'items' => [
['key' => 'frequency', 'type' => 'select_single', 'value' => 'daily',
'options' => [['key' => 'daily'], ['key' => 'weekly']]],
],
],
]);Access nested leaves via property chains or dotted-path lookup:
$user->preferences->appearance->dark_mode->value;
$user->preferences->dataField('appearance.accent')->value;
$user->preferences->notifications->frequency->value;Each leaf can carry inline Laravel validation rules. Run them with
validate():
$user->preferences = DataField::section(items: [
['key' => 'name', 'type' => 'text', 'validations' => ['required', 'min:2']],
['key' => 'age', 'type' => 'number', 'validations' => ['required', 'numeric', 'min:18']],
]);
try {
$user->preferences->validate();
} catch (\Illuminate\Validation\ValidationException $e) {
// $e->errors() — dotted paths, e.g. 'step_1.age' for nested
}Notes:
validate()is a guard, not a filter — on success it returns the values unchanged. If you want Laravel's "only validated keys" shape, callValidator::make(...)->validated()directly.- For
select_single/select_multiplewith anoptionslist, anRule::in(...)rule is auto-derived so out-of-options values fail validation without you having to repeat the option keys invalidations. - Hidden fields (resolved via
visible_if) are skipped — their stored values are preserved on read and not deleted on write.
To validate on save, call validate() inside your own saving observer:
static::saving(function ($model) {
if ($model->preferences) {
$model->preferences->validate();
}
});A leaf's default is returned by ->value when no value has been set.
Explicit null overrides the default — callers chose to clear it.
$df = new DataField([
'key' => 'plan', 'type' => 'text', 'default' => 'free',
]);
$df->value; // 'free' (from default)
$df->value = 'pro';
$df->value; // 'pro'
$df->value = null;
$df->value; // null (explicit override)Mark a field as visible only when a sibling has a specific value. Currently equality-based; multiple keys mean AND.
DataField::section(items: [
['key' => 'has_phone', 'type' => 'bool', 'value' => false],
[
'key' => 'phone', 'type' => 'text',
'visible_if' => ['has_phone' => true],
'validations' => ['required'],
],
]);Hidden fields skip validation; their stored values are preserved.
Three container shortcuts plus a generic leaf and a recursive fromArray:
DataField::section(?string $key = null, array $items = [], array $extra = []): self
DataField::step(?string $key = null, array $items = [], array $extra = []): self
DataField::group(?string $key = null, array $items = [], array $extra = []): self
DataField::leaf(FieldType|string $type, mixed $value = null, array $extra = []): self
DataField::fromArray(array $node): selfExamples:
use Ssntpl\DataFields\Support\DataField;
use Ssntpl\DataFields\Support\FieldType;
DataField::section('preferences', items: [...]);
DataField::leaf(FieldType::Date, '2026-06-15', ['key' => 'expires_at']);
DataField::leaf('number', 14, ['key' => 'fontsize', 'label' => 'Font size']);Containers iterate over their items:
foreach ($user->preferences as $field) {
echo $field->key . ' = ' . $field->value . PHP_EOL;
}
count($user->preferences); // count of itemsArrayAccess works by both index and key:
$user->preferences[0]; // first DataField
$user->preferences['dark_mode']; // DataField with key 'dark_mode'
unset($user->preferences['dark_mode']);Use row mode when you need cross-row queries by field key/value (e.g.
"find all users with plan = 'pro'"), per-field granular updates, BI/
reporting tools that expect normalised data, or DB-level constraints on
individual fields.
Add the trait to your model:
use Ssntpl\DataFields\Concerns\HasDataFields;
class Product extends Model
{
use HasDataFields;
}That's it — the trait wires up the polymorphic fields() relationship
against the package's data_fields table.
$product->fields()->create([
'key' => 'sku',
'type' => 'text',
'value' => 'WIDGET-001',
'label' => 'Stock keeping unit',
]);
// Read
$product->fields; // Collection<DataRow>
$product->fields()->where('key', 'sku')->first()->value;
// Cast across all rows
foreach ($product->fields as $row) {
echo $row->label . ' = ' . $row->value . PHP_EOL;
}Rows store typed values: $row->value returns the cast PHP type
(bool, float, Carbon, File, etc.) based on the row's type.
The trait provides two convenience methods for working with a single field by key:
$product->getFieldValue('sku'); // cast value or null
$product->setFieldValue('sku', 'NEW-001'); // upsert by key
$product->setFieldValue('weight', 2.5, 'number'); // type on first setsetFieldValue creates the row if absent and updates if present. The third
argument accepts a FieldType enum or a raw string; it defaults to text
on create and preserves the existing type on update.
Subclass DataRow to add custom attributes, accessors, or methods:
use Ssntpl\DataFields\Models\DataRow;
class CustomDataRow extends DataRow
{
protected $extraFillable = ['source_system'];
public function getFillable()
{
return array_merge(parent::getFillable(), $this->extraFillable ?? []);
}
public function isFromExternalSystem(): bool
{
return $this->source_system !== null;
}
}Point the config at your subclass:
// config/data-fields.php
return [
'data_row_model' => App\Models\CustomDataRow::class,
];The fields() relationship will now hydrate as CustomDataRow instances.
Storing validations rules alongside the field works — but note that row
mode does not auto-run those rules. The rules are persisted as field
metadata; running them is the consuming application's job (typically before
calling create() / update()):
$product->fields()->create([
'key' => 'price',
'value' => '99.99',
'type' => 'number',
'validations' => ['required', 'numeric', 'min:0'], // stored only
]);If you want auto-running rules, use cast mode — $df->validate() runs them.
The package supports 12 leaf types and 3 container types. All available as
both literal strings and as cases on the FieldType PHP enum.
| Type | Stored as | Read returns |
|---|---|---|
bool |
'1' / '0' (row), native bool (cast) |
bool |
text |
string |
string |
number |
string (row), float (cast) |
float |
select_single |
string |
string |
select_multiple |
JSON array of strings | array<string> |
date |
'YYYY-MM-DD' string |
string |
time |
'HH:MM:SS' string |
string |
datetime |
'YYYY-MM-DD HH:MM:SS' string |
\Carbon\Carbon |
file |
{model_type, model_id} JSON |
\Ssntpl\LaravelFiles\Models\File or null |
files |
array of {model_type, model_id} |
array<File> (always a list) |
json |
JSON | decoded array |
array |
JSON list | array |
Lenient string decoding on read — for json, array, and
select_multiple, the read path will json_decode a stored string if it
encounters one (recovery path for double-encoded or migrated legacy data).
If you want to store an opaque string verbatim, use the text type
instead; json is for structured data and writes always store the native
PHP structure.
| Type | Notes |
|---|---|
step |
A step/page in a wizard form |
section |
A logical grouping of related fields |
group |
An inline cluster, smaller than a section |
All three are semantically equivalent inside the package — the choice is a hint to your UI layer.
For type safety in your code, use Ssntpl\DataFields\Support\FieldType:
use Ssntpl\DataFields\Support\FieldType;
FieldType::Bool->value; // 'bool'
FieldType::SelectSingle->isLeaf(); // true
FieldType::Section->isContainer(); // true
FieldType::leaves(); // list of leaf cases
FieldType::containers(); // list of container cases
DataField::leaf(FieldType::DateTime, now(), ['key' => 'last_seen']);The enum is the in-memory type; JSON storage and the row-mode type column
stay as strings.
The file and files types store a reference to a File model from the
ssntpl/laravel-files package.
Pass a File instance, the package handles the rest:
$file = File::find(123);
$user->preferences = DataField::section(items: [
['key' => 'avatar', 'type' => 'file', 'value' => $file],
]);
$user->save();
$user->preferences->avatar->value; // File instance
$user->preferences->avatar->value->url; // works as any FileFor files (multiple), pass an array — even a single File is wrapped to a
list:
$user->preferences->addField([
'key' => 'attachments', 'type' => 'files',
'value' => [$f1, $f2, $f3],
]);
$user->preferences->attachments->value; // array<File>An empty files field round-trips as [], not null.
The published config (config/data-fields.php) is small:
return [
// Row-mode Eloquent model. Subclass DataRow and point at it to add
// custom attributes/behaviour.
'data_row_model' => \Ssntpl\DataFields\Models\DataRow::class,
// Enable created_at / updated_at on the `data_fields` table.
// Off by default — most consumers don't need per-row timestamps.
'data_fields_timestamps' => false,
// When true, the service provider loads the package's migration
// directly — no `vendor:publish` needed.
'auto_load_migrations' => false,
];| Method | Notes |
|---|---|
new DataField($node) / fromArray($node) |
Construct from a node array; throws on malformed input |
static leaf(FieldType|string $type, $value, array $extra = []) |
Leaf factory |
static section(?string $key, array $items, array $extra = []) |
Section container factory |
static step(?string $key, array $items, array $extra = []) |
Step container factory |
static group(?string $key, array $items, array $extra = []) |
Group container factory |
isLeaf() / isContainer() |
Type-based predicates |
isVisible(?array $siblingValues = null) |
Resolves visible_if |
getValue() / setValue($v) |
Read/write the leaf value (honours default) |
$df->{$childKey} |
Property access — returns child DataField or null |
$df->dataField($dottedPath) |
Explicit path lookup, deep |
$df->addField($node) / $df->removeField($key) |
Container-only mutation |
Iterable, ArrayAccess, Countable |
Walk and index children |
validate() |
Runs Laravel rules; throws ValidationException |
toArray() / jsonSerialize() |
Storage-form serialisation |
| Method | Notes |
|---|---|
owner() |
Polymorphic morphTo |
fields() |
Children via self-polymorphism (rare in practice) |
duplicate() / duplicateInto($owner) |
Clone with re-parented children |
delete() |
Transactional cascade to files + children |
| Method | Notes |
|---|---|
fields() |
morphMany to DataRow |
getFieldValue($key) |
Cast value or null |
setFieldValue($key, $value, $type = null) |
Upsert by key |
| Method | Notes |
|---|---|
isLeaf() / isContainer() |
Per-case predicates |
static coerce($value) |
Accept enum or string; throws on unknown |
static leaves() / static containers() |
Enumerate by kind |
Use the model's creating event to seed a default document:
static::creating(function (User $user) {
if ($user->preferences === null) {
$user->preferences = DataField::section(items: [
['key' => 'language', 'type' => 'text', 'value' => 'en'],
['key' => 'theme', 'type' => 'select_single', 'value' => 'system',
'options' => [['key'=>'system'],['key'=>'light'],['key'=>'dark']]],
]);
}
});Hook into saving:
static::saving(function (User $user) {
if ($user->preferences) {
$user->preferences->validate();
}
});When many child records share one schema (e.g., template + responses), keep the schema on the parent and store only the merged document on the child. The cast handles both shapes identically — the schema lives wherever you choose.
dataField('a.b.c') looks up by full path. To walk every leaf:
$walker = function (DataField $node) use (&$walker, &$leaves) {
if ($node->isLeaf()) {
$leaves[] = $node;
return;
}
foreach ($node->items as $child) {
$walker($child);
}
};
$leaves = [];
$walker($user->preferences);The 0.4.x release is a breaking redesign. If you were on 0.2.x with the
HasDataFieldsJson trait:
Before (0.2.x):
use Ssntpl\DataFields\Traits\HasDataFieldsJson;
class LogEntry extends Model
{
use HasDataFieldsJson;
}
$entry->setDataFieldsSchema([
['key' => 'performed_by', 'type' => 'text'],
]);
$entry->setFieldValue('performed_by', 'Rahul');
$entry->save();After (0.4.x):
use Ssntpl\DataFields\Support\DataField;
class LogEntry extends Model
{
protected $casts = [
'entry_data' => DataField::class,
];
}
$entry->entry_data = DataField::section();
$entry->entry_data->addField(['key' => 'performed_by', 'type' => 'text', 'value' => 'Rahul']);
$entry->save();For row-mode consumers, the rename DataField → DataRow and trait
namespace Traits\ → Concerns\ are the main changes:
- use Ssntpl\DataFields\Traits\HasDataFields;
+ use Ssntpl\DataFields\Concerns\HasDataFields;
- use Ssntpl\DataFields\Models\DataField;
+ use Ssntpl\DataFields\Models\DataRow;Type-string constants are gone — use either the raw string ('bool',
'text', …) or the FieldType enum cases (FieldType::Bool->value, …).
See CHANGELOG.md for the complete list of changes and rationales.
composer install
composer test # or: vendor/bin/phpunitThe test suite runs against SQLite in-memory using Orchestra Testbench.
The file / files types store a reference to a row in
ssntpl/laravel-files's files table as {model_type, model_id} JSON. On
read, the cast resolves model_type through Laravel's morph map
(Illuminate\Database\Eloquent\Relations\Relation::morphMap()) and rejects
any class that is not Ssntpl\LaravelFiles\Models\File or a subclass — so
a tampered value cannot autoload arbitrary classes. If you have subclassed
the File model, ensure your subclass extends
Ssntpl\LaravelFiles\Models\File.
If you discover a security vulnerability, please email
abhishek.sharma@ssntpl.in instead of opening a public issue.
See CHANGELOG.md for a detailed record of changes per release.
Issues and pull requests are welcome at github.com/ssntpl/data-fields.
When sending a PR:
- Fork the repo and create a feature branch.
- Add tests covering the change.
- Run
composer testand make sure everything is green. - Update
CHANGELOG.mdunder the[Unreleased]section.
- Abhishek Sharma — abhishek.sharma@ssntpl.in — https://ssntpl.com
- All contributors
The MIT License (MIT). See LICENSE.md.