ssntpl / data-fields
Laravel Data Fields.
Requires
- php: ^8.2
- laravel/framework: ^11.0|^12.0
- ssntpl/laravel-files: ^0.1
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-06-20 16:46:08 UTC
README
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'
Table of contents
- 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
Installation
Install via Composer:
composer require ssntpl/data-fields
Publish the config (optional — only needed if you want to override defaults):
php artisan vendor:publish --tag=data-fields-config
Row mode
If you're using row mode (the data_fields table), publish and run the
migration:
php artisan vendor:publish --tag=data-fields-migrations php artisan migrate
If 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
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(); });
Requirements
- PHP 8.2+
- Laravel 11.0 or 12.0
ssntpl/laravel-files^0.1 (required — used by thefile/filesfield types)
Choosing a mode
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.
Cast mode
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.
Setup
-
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, ];
Defining and writing
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.
Reading values
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; // null
Mutating values
The 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();
Adding and removing fields at runtime
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.
Containers and nesting
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;
Validation
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(); } });
Default values
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)
Conditional visibility (visible_if)
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.
Factories
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): self
Examples:
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']);
Iteration and array access
Containers iterate over their items:
foreach ($user->preferences as $field) { echo $field->key . ' = ' . $field->value . PHP_EOL; } count($user->preferences); // count of items
ArrayAccess 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']);
Row 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.
Row mode setup
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.
Working with rows
$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.
Single-key helpers
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 set
setFieldValue 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.
Custom row model
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.
Validation rules in row mode
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.
Field types reference
The package supports 12 leaf types and 3 container types. All available as
both literal strings and as cases on the FieldType PHP enum.
Leaves
| 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.
Containers (cast mode only)
| 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.
The FieldType enum
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.
File and files types
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 File
For 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.
Configuration
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, ];
API reference
Ssntpl\DataFields\Support\DataField (cast value object)
| 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 |
Ssntpl\DataFields\Models\DataRow (row-mode Eloquent model)
| 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 |
Ssntpl\DataFields\Concerns\HasDataFields (row-mode trait)
| Method | Notes |
|---|---|
fields() |
morphMany to DataRow |
getFieldValue($key) |
Cast value or null |
setFieldValue($key, $value, $type = null) |
Upsert by key |
Ssntpl\DataFields\Support\FieldType (enum)
| Method | Notes |
|---|---|
isLeaf() / isContainer() |
Per-case predicates |
static coerce($value) |
Accept enum or string; throws on unknown |
static leaves() / static containers() |
Enumerate by kind |
Common patterns
Per-environment defaults
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']]], ]); } });
Validating on save
Hook into saving:
static::saving(function (User $user) { if ($user->preferences) { $user->preferences->validate(); } });
Schema defined on a parent, values stored per-child
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.
Iterating leaves across containers
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);
Migrating from 0.2.x
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.
Testing
composer install composer test # or: vendor/bin/phpunit
The test suite runs against SQLite in-memory using Orchestra Testbench.
Security
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.
Changelog
See CHANGELOG.md for a detailed record of changes per release.
Contributing
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.
Credits
- Abhishek Sharma — abhishek.sharma@ssntpl.in — https://ssntpl.com
- All contributors
License
The MIT License (MIT). See LICENSE.md.