Search by

kenprz / laravel-custom-fields

KenPrz

User-defined custom fields for Eloquent models, without touching your schema.

Package info

github.com/KenPrz/laravel-custom-fields

pkg:composer/kenprz/laravel-custom-fields

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

v1.0.0 2026-11-10 02:58 UTC

This package is auto-updated.

Last update: 2026-11-10 12:32:27 UTC


README

User-defined custom fields for Eloquent models. Add a trait to a model, define fields at runtime, and store validated values, without adding columns to the model's table.

Documentation: https://kenprz.github.io/laravel-custom-fields/

class User extends Model
{
    use HasCustomFields;
}

// An admin defines a field at runtime.
CustomFields::create([
    'model' => User::class,
    'key' => 'tax_bracket',
    'label' => 'Tax bracket',
    'type' => 'select',
    'settings' => ['options' => ['a' => 'A', 'b' => 'B']],
]);

// It now works like any other attribute.
$user = User::create(['name' => 'Ken', 'tax_bracket' => 'b']);
$user->tax_bracket;            // "b"
$user->update(['tax_bracket' => 'a']);

User::whereCustomField('tax_bracket', 'a')->get();

Contents

Features

  • Custom fields act like attributes: $user->tax_bracket, fill(), create(), update(), toArray(), with Eloquent casts. Real columns always win.
  • Two tables (custom_fields, custom_field_values). Your tables are never changed.
  • 17 built-in field types, plus your own.
  • php artisan make:custom-field: a step-by-step form that writes a migration or creates the field right away.
  • Every write goes through Laravel's validator: type rules, select options, and any rules you attach to the field.
  • Global fields and per-tenant (scoped) fields, with tenant isolation on every read and write.
  • Query scopes for filtering by custom field values on MySQL, PostgreSQL and SQLite.
  • Events for every field and value change.
  • Optional caching of field definitions.

Requirements

  • PHP 8.3 or newer
  • Laravel 12.6 or newer, or Laravel 13
  • MySQL 8, PostgreSQL or SQLite. See Database compatibility.

Installation

composer require kenprz/laravel-custom-fields
php artisan migrate

The service provider registers itself through package discovery. If you turned discovery off, add KenPrz\CustomFields\CustomFieldsServiceProvider::class to bootstrap/providers.php.

Configuration

Publish the config file to change the defaults:

php artisan vendor:publish --tag=custom-fields-config
Key Default What it does
tables.fields custom_fields Name of the field definitions table. Set before migrating.
tables.values custom_field_values Name of the values table. Set before migrating.
models.field CustomField::class Field model. Your class must extend the package model.
models.value CustomFieldValue::class Value model. Your class must extend the package model.
run_migrations true Run the package migration with php artisan migrate.
strict true Throw on unknown field keys. See Validation.
scope_resolver null Class that returns the current tenant. See Multi-tenancy.
delete_values_with_model true Delete a model's values when the model is deleted.
events true Dispatch the package events.
cache.enabled false Cache field definitions across requests.
cache.store null Cache store name. null uses the default store.
cache.ttl 3600 Seconds to keep cached definitions.

Migrations

The package migration runs with your normal php artisan migrate. It creates two tables:

Table Holds
custom_fields Field definitions: model, scope, key, label, type, settings, rules, flags, order
custom_field_values One JSON value per field per model record

The values table has a foreign key to the fields table and a unique index on (field, record), so a record can never have two values for one field.

To rename the tables, publish the config and change tables before you migrate.

To edit the migration, publish it and stop the package copy from running too:

php artisan vendor:publish --tag=custom-fields-migrations
// config/custom-fields.php
'run_migrations' => false,

Key types. Scopes (tenants) can have any primary key: integers, UUIDs, ULIDs or strings such as slugs, because scope_id is a string column. entity_id, the record a value belongs to, uses Laravel's default morph key type (big integers). If the models that get custom fields use UUIDs or ULIDs, call Schema::morphUsingUuids() or Schema::morphUsingUlids() in a service provider's register() method before migrating.

Rolling back drops both tables and every stored value.

Model integration

Add the trait to any Eloquent model. That's all that's required:

use Illuminate\Database\Eloquent\Model;
use KenPrz\CustomFields\Traits\HasCustomFields;

class User extends Model
{
    use HasCustomFields;
}

Custom fields now behave like attributes:

$user = User::create(['name' => 'Ken', 'tax_bracket' => 'b']);   // no $fillable entry needed

$user->tax_bracket;                 // "b"
$user->tax_bracket = 'a';
$user->save();                      // validated, then saved with the model
$user->update(['tax_bracket' => 'b']);
$user->toArray();                   // includes tax_bracket when values are loaded
isset($user->tax_bracket);          // true

Optional model properties

class User extends Model
{
    use HasCustomFields;

    // Which custom fields act as attributes. [] or not set = every field defined for the model.
    protected $customFields = ['tax_bracket', 'birthday', 'preferences'];

    // Casts for custom field attributes, written exactly like $casts.
    protected $customFieldCasts = [
        'birthday' => 'immutable_date',
        'tax_bracket' => TaxBracket::class,          // backed enum
        'preferences' => AsCollection::class,
    ];

    // Eager load custom field values on every query, like $with.
    protected $loadCustomFields = true;
}
Property Default Like
$customFields every field $fillable, for custom fields
$customFieldCasts dates → Carbon $casts
$loadCustomFields false $with = ['customFieldValues']

How attribute access works

  • Real attributes win. A column, cast, accessor, appended attribute or relation is never replaced by a custom field. The package also refuses to create a field whose key is already one of those (see Creating and managing fields).
  • Mass assignment. Custom field attributes are always fillable, whatever $fillable and $guarded say. That's safe because every value is validated against its field definition on save, and $customFields limits which keys count. Everything else keeps your normal mass-assignment rules.
  • Saving. Assigned values are validated before any SQL runs, then written right after the model is saved, in the same transaction. A validation error throws ValidationException, and neither the model nor the values are saved. saveQuietly() writes custom fields too.
  • Required fields are not enforced on save(). Seeders and factories keep working when an admin adds a required field. Enforce them in your form requests.
  • Casts. date fields come back as Carbon (formatted Y-m-d in arrays) and datetime fields as Carbon. Anything in $customFieldCasts replaces those defaults, using Eloquent's own casting: enums, AsCollection, decimal:2, custom cast classes and so on. You can assign cast values back: $user->tax_bracket = TaxBracket::A stores "a".
  • Serialization. toArray() and JSON include custom field attributes when their values are loaded (eager loaded, or just created). It never runs a query to load them. $hidden and $visible apply to custom fields like any attribute. The raw customFieldValues relation is hidden.
  • Strict Eloquent. Model::preventAccessingMissingAttributes() still throws for keys that are neither attributes nor custom fields. Unset custom fields read as null.
  • Deleting a record deletes its stored values. For models using SoftDeletes, values are kept until the model is force deleted. This is a bulk delete, so it doesn't fire value events. Set delete_values_with_model to false to clean up yourself.

Methods

Method Returns Notes
listCustomFields() array<string, mixed> Every value keyed by field key, as stored (no casts), in field order
customFieldRules() array Validation rules for a form request
customFieldDefinitions() Collection<string, CustomField> Visible field definitions, active or not, keyed by key
customFieldValues() MorphMany Raw value rows
getCustomField($key, $default = null) mixed Stored value, no casts. Throws for unknown keys in strict mode.
setCustomField($key, $value) static Validate and write one value right away, without saving the model
syncCustomFields(array $values) static Validate and write several values right away. Only the given keys change.
forgetCustomField($key) static Delete one stored value
hasCustomField($key) bool True when a non-null value is stored. Never throws.
customFieldScope() ?Model The tenant this record's fields come from. Override it.

Query scopes: withCustomFields(), withoutCustomFields(), whereCustomField(), whereCustomFieldIn(), whereCustomFieldContains(), whereCustomFieldNull() and whereCustomFieldNotNull().

Form requests

Spread customFieldRules() into your rules. Custom fields sit next to your columns, with no prefix:

class SaveUserRequest extends FormRequest
{
    public function rules(): array
    {
        $user = $this->route('user') ?? new User;

        return [
            'name' => ['required', 'string'],
            ...$user->customFieldRules(),
        ];
    }
}
  • For a new model the rules are complete, so required custom fields must be sent.
  • For an existing model the rules start with sometimes, so a partial update only checks what was sent.
  • The rules come from the record's own scope. When the scope depends on input, build the model with it first: new Reservation(['property_id' => $this->property_id]).
  • Only fields in $customFields (or all fields, when it's empty) and only active fields are included.

Creating and managing fields

Field definitions are managed through the CustomFields facade.

use KenPrz\CustomFields\Facades\CustomFields;

$field = CustomFields::create([
    'model' => Reservation::class,       // required: model class or morph alias
    'key' => 'guest_age',                // required: a-z, 0-9 and _, starting with a letter, max 64
    'label' => 'Guest age',              // required
    'type' => 'integer',                 // required: a registered type name
    'description' => 'Age at check-in',  // optional
    'settings' => [],                    // optional: merged over the type's defaults
    'validation_rules' => ['min:18'],    // optional: extra Laravel rules
    'is_required' => true,               // optional, default false
    'is_active' => true,                 // optional, default true
    'is_searchable' => false,            // optional, stored for your own use
    'is_filterable' => false,            // optional, stored for your own use
    'sort_order' => 0,                   // optional, default 0
]);

Invalid definitions throw a ValidationException, so in a controller they come back as a normal 422 response. Unknown attributes, unknown types, unknown validation rules, bad settings and duplicate keys are all rejected.

Method Returns Notes
create(array $attributes) CustomField Creates the field in the current scope
update(CustomField $field, array $attributes) CustomField Same attributes as create, except model
find($id) ?CustomField Null when missing, deleted or in another scope
findByKey($model, $key) ?CustomField The field the current scope sees for that key
fieldsFor($model) Collection<string, CustomField> Every visible field, active or not, keyed by key
activate(CustomField $field) CustomField The field accepts values again
deactivate(CustomField $field) CustomField Rejects new values. Stored values stay readable.
reorder($model, array $keys) void Listed keys first, in order. Others keep their order after them.
delete(CustomField $field) void Soft delete. Values are kept.
restore(CustomField $field) CustomField Undo a soft delete
forceDelete(CustomField $field) void Deletes the field and all its values
rules($model, bool $partial = false) array Validation rules keyed by field key
validate($model, array $input, bool $partial = false) array Validated, normalized values
registerType($name, $type) void See Custom field types
forScope(?Model $scope) / global() CustomFieldManager See Multi-tenancy
check(array $attributes) array Every check create() runs, without saving
keyError($model, $key) ?string Why a key can't be used, or null
dropIfExists($model, $key) void Force delete a field by key, including a soft-deleted one. For migrations.
flush() void Forget loaded and cached definitions

Keys can't shadow the model. A field's key can't be a column of the model's table, a cast, an accessor, an appended attribute, a relation or any other method on the model, because $model->key would be ambiguous. Such keys are rejected with a validation error on key.

Keys stay reserved. A soft-deleted field keeps its key, so you can restore it later. You can't create a new field with the same key until the old one is force deleted.

Changing a field's type is only allowed while the field has no stored values. Once values exist, the update is rejected with a validation error on type. To change the type, create a new field, copy the values over in your own code, then delete the old field. This rule exists so that a type change can never silently corrupt stored data.

Changing settings or rules is allowed at any time and only affects new writes. For example, if you remove a select option, records that already hold it keep it.

Artisan command

Run it with no arguments for a step-by-step form:

php artisan make:custom-field

It asks for each part of the field in order and checks every answer as soon as you give it:

Step Checked right away Skippable
Where to create it (migration or database) defaults to migration
Model the class exists
Scope (database mode only) the record exists yes, for a global field
Key format, existing fields, a migration that hasn't run yet, the model's columns and methods
Label length defaults to the key as a headline
Type registered defaults to text
Options (select types) at least one, no duplicates an empty value finishes the list
Required defaults to no
Description yes
Extra validation rules every rule exists yes

It then shows a summary and asks before creating anything.

Every step can also be passed as an option, which skips its question. With --no-interaction, the key, --model and --type are required:

php artisan make:custom-field tax_bracket \
    --model=User --type=select --options="a:Bracket A,b:Bracket B" \
    --required --rules="string" --description="Used for invoices" --no-interaction
Option Meaning
key (argument) The field key
--model A model class, a short name such as User (resolved in App\Models), or a morph alias
--type A registered field type
--label Defaults to the key as a headline
--options Choices for select types: value:Label,value:Label. A value without a label uses itself as the label.
--rules Extra Laravel rules separated by |. A rule that contains | itself, such as some regexes, has to be added to the generated migration by hand.
--description Help text
--required Make the field required
--mode migration (default) or db
--scope Model:id, for example Property:5. Database mode only.
--path Where to write the migration, relative to the base path
--force Skip the production confirmation in database mode

Migration mode (default)

Writes database/migrations/{timestamp}_add_tax_bracket_custom_field_to_users.php:

public function up(): void
{
    CustomFields::global()->create([
        'model' => \App\Models\User::class,
        'key' => 'tax_bracket',
        'label' => 'Tax Bracket',
        'type' => 'select',
        'settings' => [
            'options' => [
                'a' => 'Bracket A',
                'b' => 'Bracket B',
            ],
        ],
        'is_required' => true,
    ]);
}

public function down(): void
{
    CustomFields::global()->dropIfExists(\App\Models\User::class, 'tax_bracket');
}

Use it for fields your code depends on. The field is created by php artisan migrate in every environment, and rolling back deletes it with all its values. Nothing touches the database until you migrate, so it never asks for confirmation. A scope isn't allowed here, because record ids differ between environments.

Database mode

--mode=db creates the field right away on the current database and prints its id. Use it for one-off fields on a live system, or for scoped fields. In production it asks for confirmation first, the same way php artisan migrate does. --force skips the question.

Field types

Type Accepts Stored and returned as Settings
text string string max_length (255)
textarea string string max_length (65535)
email email address string max_length (255)
url absolute URL string max_length (2048)
phone digits, spaces, ( ) . -, leading + trimmed string
number int, float, numeric string int or float
integer int or integer string int
decimal number with at most places decimals string padded to places, e.g. "12.50" places (2)
boolean true, false, 1, 0, "1", "0" bool
date anything Laravel's date rule accepts "Y-m-d"
datetime anything Laravel's date rule accepts UTC ISO 8601, e.g. "2026-11-09T14:30:00Z"
time "H:i" or "H:i:s" "H:i:s"
select / radio one option key string options (required)
multiselect / checkbox a list of option keys list of strings, duplicates removed options (required)
json array, int, float, bool, or a string of JSON text decoded value

Notes:

  • options is a map of stored value to label: ['vip' => 'VIP', 'walk_in' => 'Walk-in']. The stored value is always the key, as a string.
  • decimal is stored as a string so money keeps its exact value. It still compares numerically in queries.
  • A datetime without a timezone is read in your app timezone, then converted to UTC.
  • json treats strings as JSON text: '{"a":1}' is stored as ['a' => 1], and 'hello' is rejected. To store a plain string, use a text field.
  • phone only checks the characters used. It doesn't know real numbering plans. For that, register your own type backed by a library such as libphonenumber.

Assigning values

The usual way is through attributes, as shown in Model integration. To write values without saving the model, use the explicit methods:

$user->setCustomField('tax_bracket', 'b');

$user->syncCustomFields([
    'tax_bracket' => 'b',
    'nickname' => 'Ken',
]);

$user->forgetCustomField('nickname');

Both ways follow the same rules:

  • Every value is validated before anything is written. If one value fails, nothing is saved and a ValidationException lists every error at once.
  • Values are normalized by their type before they're stored, so '30' on an integer field is stored as 30.
  • An empty string is stored as null, the same as Laravel's ConvertEmptyStringsToNull middleware does for requests. An empty array is stored as [].
  • Writing a value that equals the stored one doesn't touch the database or fire an event.
  • If two requests write the first value for the same record and field at the same time, the unique index catches the second insert and it becomes an update. The last write wins.

The explicit methods need a saved model and throw CustomFieldException otherwise. Attribute assignment works on new models too.

Retrieving values

$user->tax_bracket;                       // cast value, null when not set
$user->listCustomFields();                // ['tax_bracket' => 'b', ...], as stored
$user->getCustomField('birthday');        // "1990-05-01", as stored
$user->getCustomField('missing', 'n/a');  // "n/a"
$user->hasCustomField('tax_bracket');     // true

Stored values are in the normalized form the type stores. For example, a date is "2026-11-09" and a decimal is "12.50". Attribute access applies casts on top of that.

Eager load values for lists:

$users = User::withCustomFields()->get();   // or $loadCustomFields = true on the model

foreach ($users as $user) {
    $user->tax_bracket;   // no extra queries per record
}

That's three queries in total, however many records there are: the records, their values, and the field definitions (loaded once per model and scope per request).

Inactive fields still return their stored values. Soft-deleted fields are hidden until restored.

Validation

Each value is checked against, in order:

  1. required (when is_required) or nullable
  2. The type's rules, such as integer or the select options
  3. The field's own validation_rules

validation_rules takes any rule Laravel understands as a string: built-in rules ('min:18', 'regex:/^[A-Z]+$/', 'exists:rooms,number'), rules registered with Validator::extend(), and the class name of a rule object that implements Illuminate\Contracts\Validation\ValidationRule. Unknown rules are rejected when the field is saved, not when a value is written.

Validation errors are a standard ValidationException, keyed by field key, using field labels:

guest_age: The Guest age field must be at least 18.

Other checks:

  • Unknown keys. With strict on (the default), writing an unknown key fails validation, and reading or querying one throws CustomFieldException. With strict off, unknown keys are ignored on write, read as the default, and match nothing in queries.
  • Inactive fields reject writes, including forgetCustomField().
  • Required fields reject null, '', [] and forgetCustomField().
  • Partial updates. Saves and syncCustomFields() only check the keys you pass, so a required field you didn't pass isn't reported as missing. To demand every required field, use the rules of a new model, in a form request or directly:
Validator::make($input, (new User)->customFieldRules())->validate();

Treat field definitions as configuration, not user input: anyone who can edit a field's validation_rules can add rules such as exists: that query your database. Put field management behind your own authorization (policies, gates or middleware).

Multi-tenancy and scopes

A field can be global (every record of the model sees it) or scoped to one model instance: a tenant, property, organization, account or anything else. The package doesn't assume any tenant column.

// Only reservations of $hotel see this field.
CustomFields::forScope($hotel)->create([
    'model' => Reservation::class,
    'key' => 'vip_reference',
    'label' => 'VIP Reference',
    'type' => 'text',
]);

Rules:

  • With no scope, only global fields are visible.
  • With a scope, global fields plus that scope's fields are visible.
  • If a scoped field and a global field share a key, the scoped one wins inside that scope.
  • Fields and values of other scopes are never returned, and their fields can't be updated, deactivated or deleted from another scope (CustomFieldException).
  • Two scopes can use the same key independently.

Where the scope comes from, in order:

  1. CustomFields::forScope($scope), or CustomFields::global() for global only.
  2. Otherwise, the configured scope_resolver.
  3. Otherwise, no scope.

A resolver connects your existing tenancy setup:

use Illuminate\Database\Eloquent\Model;
use KenPrz\CustomFields\Contracts\ScopeResolver;

class CurrentTenant implements ScopeResolver
{
    public function resolve(): ?Model
    {
        return auth()->user()?->team;
    }
}
// config/custom-fields.php
'scope_resolver' => App\CustomFields\CurrentTenant::class,

Per-record scope. When a record knows its own tenant, override customFieldScope(). Attribute access, saves, customFieldRules() and the explicit methods on that record then use it:

class Reservation extends Model
{
    use HasCustomFields;

    public function customFieldScope(): ?Model
    {
        return $this->property;
    }
}

Queries (whereCustomField() and friends) can't use customFieldScope(), because no single record is known yet. They use the resolver, or global fields only when there is none. forScope() returns a copy, so it doesn't change queries. To query one tenant's fields without a resolver, bind a scoped manager for the request:

app()->instance(CustomFieldManager::class, CustomFields::forScope($hotel));

Querying

Reservation::whereCustomField('guest_classification', 'vip');      // equals
Reservation::whereCustomField('guest_age', '>=', 18);              // = != <> > >= < <=
Reservation::whereCustomFieldIn('guest_classification', ['vip', 'corporate']);
Reservation::whereCustomFieldContains('amenities', 'parking');     // list has the option
Reservation::whereCustomFieldContains('notes', 'late arrival');    // substring, case-insensitive
Reservation::whereCustomFieldNull('notes');                        // missing or null
Reservation::whereCustomFieldNotNull('notes');
Reservation::whereCustomField('notes', null);                      // same as whereCustomFieldNull

How values compare:

  • The comparison value is validated and normalized with the field type first. '30' finds an integer field holding 30, and a value that doesn't fit the type throws a ValidationException.
  • number, integer and decimal fields compare numerically.
  • date, datetime and time fields compare in time order.
  • Other strings compare byte by byte on every database. In byte order uppercase letters come before lowercase ones.
  • = and != on other types compare the whole stored value, so they work for booleans, lists and JSON too.
  • != and the comparison operators skip records that have no value for the field.
  • Ordering operators (>, < and so on) on booleans, lists or JSON throw InvalidArgumentException.

Security. Operators are checked against a fixed list, and every value is passed as a query binding. Values never become part of the SQL.

Performance. Each filter is a WHERE EXISTS subquery on the values table. The subquery uses the unique (field, entity type, entity id) index, so equality filters on reasonably sized tables are fine. Comparisons that cast the stored JSON (numbers, dates, text ordering, substring search) can't use an index on the value itself, so they scan every value of that field. If you need fast sorting or range filters on one field across very large tables, consider a real column for it.

Custom field types

Implement KenPrz\CustomFields\Contracts\FieldType, or extend KenPrz\CustomFields\Types\Type for empty defaults:

use KenPrz\CustomFields\Models\CustomField;
use KenPrz\CustomFields\Types\Type;

class CurrencyFieldType extends Type
{
    // Laravel rules for one value. required/nullable and the field's own rules are added for you.
    public function rules(CustomField $field): array
    {
        return ['string', 'size:3', 'alpha'];
    }

    // Turn a valid value into what gets stored (must be JSON encodable). Never receives null.
    public function normalize(mixed $value, CustomField $field): mixed
    {
        return strtoupper($value);
    }

    // Settings a new field starts with.
    public function defaultSettings(): array
    {
        return [];
    }

    // Rules for this type's settings, keyed by setting name.
    public function settingsRules(): array
    {
        return [];
    }
}

Register it in a service provider's boot() method:

CustomFields::registerType('currency', CurrencyFieldType::class);

Registering a built-in name (such as phone) replaces the built-in type. Custom types compare as text in queries. They compare numerically if they extend NumberType.

Events

All events are in KenPrz\CustomFields\Events and are dispatched after the surrounding database transaction commits. A rolled-back write fires nothing. They use SerializesModels, so queued listeners get fresh models. The deleted events carry the models as they were, since they can't be fetched again.

Event Properties
CustomFieldCreated field
CustomFieldUpdated field
CustomFieldActivated field
CustomFieldDeactivated field
CustomFieldDeleted field, forced
CustomFieldValueCreated value, field, entity
CustomFieldValueUpdated value, field, entity, oldValue
CustomFieldValueDeleted value, field, entity

Events fire for changes made through the facade, the trait and attribute saves. Changes you make directly to the package tables with Eloquent or the query builder don't fire them. Turn all events off with 'events' => false.

Caching

Field definitions are loaded at most once per model and scope per request (or per queue job or Octane request). To also cache them across requests, turn on the cache:

'cache' => ['enabled' => true, 'store' => null, 'ttl' => 3600],

Saving, deleting or restoring a field through the package or through Eloquent clears the cache for every model and scope. If you change the fields table some other way, such as raw SQL or DB::table(), call CustomFields::flush().

Values are never cached. They're always read from the database, so they can't go stale. Redis isn't required: any Laravel cache store works.

Performance

Measured with the included benchmark: 10 custom fields per record, values for every field, PHP 8.5, databases in Docker on one laptop. Absolute numbers depend on your hardware; the shape is what matters.

Reading (20,000 records, 200,000 values, loaded at once):

SQLite MySQL 8.4 PostgreSQL 17
Plain Eloquent, same rows (baseline) 3.9 s 4.4 s 3.9 s
withCustomFields()->get() 4.6 s 5.0 s 5.2 s
listCustomFields() on every record 1.9 s 1.9 s 2.2 s
toArray() on every record 2.9 s 2.8 s 3.2 s
  • Eager loading is always 2 queries (records + values), plus 1 for field definitions per request, however many records or fields. Memory is the same as plain Eloquent: about 330 MB for these 220,000 models.
  • For bigger sets, chunk: chunkById(1000) over 100,000 records ran in about 25 s with flat memory (1 MB growth), 2 queries per chunk.

Filtering (100,000 records, 1,000,000 values, one query each, exact results checked):

SQLite MySQL 8.4 PostgreSQL 17
equality on a select 0.16 s 0.25 s 0.06 s
numeric range 0.18 s 0.48 s 0.09 s
date range 0.18 s 0.43 s 0.05 s
list contains 0.18 s 0.46 s 0.09 s
text contains 0.20 s 0.43 s 0.06 s
two filters combined 0.22 s 0.44 s 0.10 s

Filters grow linearly with the number of values for that field. See Querying for why there is no index on the values themselves.

Writing: about 1.5–4 ms per record for 3 fields (validation, one read of the existing rows, one update per changed value). Unchanged values aren't written. A transaction around a bulk loop doesn't change much; the cost is Eloquent and the validator, not the commit.

Run it yourself:

PERF_RECORDS=100000 composer bench

Database compatibility

Database Status
MySQL 8 Supported. Tested on 8.4.
PostgreSQL Supported. Tested on 17. Values are stored as jsonb.
SQLite Supported. Tested on the version bundled with PHP. Needs the JSON functions (built in since 3.38).
MariaDB Not supported. Reading and writing may work, but the query scopes throw.
SQL Server Not supported.

The test suite runs against all three supported databases in CI.

Testing

composer test      # PHPUnit
composer analyse   # Larastan, level 8
composer lint      # Pint
composer bench     # large-dataset benchmarks, see Performance

Mutation testing uses Infection. It isn't a dev dependency (it needs a newer symfony/console than the oldest supported Laravel 12), so install it when you want it. It needs pcov or Xdebug:

composer require --dev infection/infection
vendor/bin/infection --threads=max

What CI checks on every push:

  • The full suite on PHP 8.3–8.5 × Laravel 12/13, lowest and newest dependencies.
  • The full suite and the benchmarks on MySQL 8.4 and PostgreSQL 17.
  • 100% line coverage across the three databases.
  • Mutation testing: Infection makes about 1,000 small deliberate changes to the code and the tests must catch at least 99% of them. Changes that can't alter behaviour are listed, with the reason, in infection.json5.

The suite runs on in-memory SQLite by default. To run it against MySQL or PostgreSQL, set the usual DB_* variables:

DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=testing \
DB_USERNAME=postgres DB_PASSWORD=secret composer test

Limitations

  • Overridden model methods. The trait overrides getAttribute(), setAttribute(), isFillable(), fillableFromArray(), attributesToArray() and save(). If your model (or another trait) overrides one of them too, call the trait's version or attribute access won't fully work. A custom save() still gets validation and writes through model events, just without the shared transaction.
  • First fill per request runs one small query for the model's field keys, unless $customFields lists them.
  • Type changes are blocked once a field has values. See Creating and managing fields.
  • Queries use the request scope, not each record's customFieldScope().
  • Large-scale filtering and sorting. There's no index on the values themselves. See Querying.
  • No sorting by custom field values (orderByCustomField). Sorting on EAV data can't use an index on any database.
  • Object key order. MySQL and PostgreSQL may reorder the keys of objects stored as values (in json fields). Field settings, such as the order of select options, are kept as given.
  • Text search in SQLite only ignores case for ASCII letters.
  • One connection. The package tables use the default database connection. To move them, point models.field and models.value at your own subclasses that set $connection, and run the migration on that connection.
  • Changing the morph map after values are stored orphans those values, because model_type and entity_type hold the old names.
  • Messages for unknown and inactive fields are in English and aren't translatable yet.

Upgrading

This is the first release. Breaking changes will only ship in a new major version, with steps in CHANGELOG.md.

Example: an API controller

class UserController
{
    public function store(SaveUserRequest $request)
    {
        return User::create($request->validated());       // 201 includes the custom fields
    }

    public function update(SaveUserRequest $request, User $user)
    {
        $user->update($request->validated());

        return $user->load('customFieldValues');
    }
}

With SaveUserRequest from Form requests, a missing required field or an invalid value comes back as a normal 422 response, next to the errors for your columns. Keys without a rule are left out of validated(), as usual in Laravel, so they never reach the model.

License

MIT. See LICENSE.