ssntpl/data-fields

There is no license information available for the latest version (v0.4.1) of this package.

Laravel Data Fields.

Maintainers

Package info

github.com/ssntpl/data-fields

pkg:composer/ssntpl/data-fields

Statistics

Installs: 43

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.4.1 2026-06-20 16:45 UTC

README

Latest Version on Packagist Total Downloads License

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 DataField document (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_fields table. 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

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 the file / files field 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

  1. Add a json column to your model's table:

    Schema::table('users', function (Blueprint $table) {
        $table->json('preferences')->nullable();
    });
  2. Add the cast to your model — write DataField::class directly; the package resolves to its internal cast via Laravel's Castable interface:

    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, call Validator::make(...)->validated() directly.
  • For select_single / select_multiple with an options list, an Rule::in(...) rule is auto-derived so out-of-options values fail validation without you having to repeat the option keys in validations.
  • 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 DataFieldDataRow 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:

  1. Fork the repo and create a feature branch.
  2. Add tests covering the change.
  3. Run composer test and make sure everything is green.
  4. Update CHANGELOG.md under the [Unreleased] section.

Credits

License

The MIT License (MIT). See LICENSE.md.