Data Model
Architecture Overview
The Custom Fields plugin employs a Hybrid Entity-Attribute-Value (EAV) with Type Polymorphism design that balances flexibility with performance. Unlike traditional EAV models that suffer from type conversion overhead and poor query performance, this architecture uses typed storage columns and strategic indexing to maintain database-level optimizations while enabling dynamic field creation.
Entity Relationships
| Parent | Relationship | Child | Description |
|---|---|---|---|
| Entity (polymorphic) | one-to-many | custom_field_sections | Each entity type has its own sections |
custom_field_sections | one-to-many | custom_fields | Sections contain field definitions |
custom_fields | one-to-many | custom_field_options | Select/checkbox fields have options |
custom_fields | one-to-many | custom_field_values | Fields store values per entity instance |
| Entity (polymorphic) | one-to-many | custom_field_values | Entity instances have field values |
Table Schemas
| Column | Type | Description |
|---|---|---|
id | bigint | Primary key |
entity_type | string | Polymorphic entity class |
code | string | Unique per entity type (+ tenant, when multi-tenancy is enabled) |
name | string | Display name |
type | string | Section type |
width | string | Section layout width (CustomFieldWidth enum: 25/33/50/66/75/100). Requires the UI_SECTION_WIDTH_CONTROL feature. |
sort_order | int | Display order |
active | bool | Enabled flag |
system_defined | bool | Protected from user deletion |
settings | json | Additional configuration |
tenant_id | bigint | Optional multi-tenancy |
| Column | Type | Description |
|---|---|---|
id | bigint | Primary key |
custom_field_section_id | bigint | Parent section |
entity_type | string | Polymorphic entity class |
code | string | Unique per entity type and section (+ tenant, when multi-tenancy is enabled) — not globally unique, so the same code can exist in two different sections. See Builder Scoping |
name | string | Display name |
type | string | Field type (text, number, etc.) |
lookup_type | string | For lookup fields |
width | string | Layout width |
sort_order | int | Display order |
validation_rules | json | Capability-driven validation config (e.g. required, min_value, decimal_places) |
active | bool | Enabled flag |
system_defined | bool | Protected from user deletion |
settings | json | Type-specific configuration |
tenant_id | bigint | Optional multi-tenancy |
| Column | Type | Description |
|---|---|---|
id | bigint | Primary key |
custom_field_id | bigint | Parent field |
name | string | Option label |
sort_order | int | Display order |
settings | json | Additional configuration |
tenant_id | bigint | Optional multi-tenancy |
| Column | Type | Description |
|---|---|---|
id | bigint | Primary key |
entity_type | string | Polymorphic entity class |
entity_id | bigint | Polymorphic entity ID |
custom_field_id | bigint | Field definition |
string_value | text | String and file types, and single-choice types when option keys are strings |
text_value | longtext | Text types: text input, textarea, rich editor, markdown, color picker |
boolean_value | bool | Checkbox and toggle |
integer_value | bigint | Number, and single-choice types when option keys are integers |
float_value | double | Currency |
date_value | date | Date |
datetime_value | datetime | Date Time |
json_value | json | Every multi-choice type: email, phone, link, tags, multi-select, checkbox list, record |
tenant_id | bigint | Optional multi-tenancy |
How a Value Is Stored
Each record holds one row per field in custom_field_values. The row uses one typed column and leaves the others null.
The write path
Every write goes through the same four steps, whether it comes from a Filament form, an import, or your own code.
saveCustomFields()orsaveCustomFieldValue()on the model finds or creates the value row.CustomFieldValue::setValue()picks the column from the field type's data type.SafeValueConverter::toDbSafe()casts the value to that column's PHP type.- The field type's
normalize()rewrites each string into its stored form.
$company->saveCustomFieldValue($websiteField, ['https://Example.com/Pricing/']);
$company->getCustomFieldValue($websiteField);
// ['https://example.com/Pricing']
saveCustomFields() takes an array keyed by field code. A field whose code is missing from the array is saved as null.
One column per data type
The field type's data type decides the column. The field types table lists the data type of every built-in type.
| Data type | Column | PHP value read back |
|---|---|---|
| String, File | string_value | string |
| Text | text_value | string |
| Numeric | integer_value | int |
| Float | float_value | float |
| Boolean | boolean_value | bool |
| Date | date_value | date instance |
| DateTime | datetime_value | date-time instance |
| Single-choice | integer_value, or string_value when option keys are strings | option key |
| Multi-choice | json_value | array |
Email, phone and link are multi-choice types. They are stored as a JSON list in json_value, even when the field allows one value.
The stored form of each type
Normalization runs on strings only. For a list it runs on each item, drops an item that becomes empty, and collapses items that become equal. A list that holds an array, such as the rows of a repeater-style field, is stored as given. A single value that becomes empty is stored as null.
| Type | Typed | Stored |
|---|---|---|
| Phone | +1 (415) 555-0100 | +14155550100 |
| Phone with an extension | +1 415 555 0100 ext. 12 | +14155550100;ext=12 |
Phone without a leading + | 415 555 0100 | 415 555 0100 (trimmed, not parsed) |
| Link | https://Example.com/Pricing/ | https://example.com/Pricing |
| Link without a scheme | Acme.com/Path/ | acme.com/Path |
Jane@Example.com | Jane@Example.com (as typed) | |
| Every other type | as typed | as typed |
A link keeps its scheme because http:// and https:// can open different pages.
A date-time value is stored exactly as it is given. The package converts no timezone, so pass every value in one timezone, such as UTC.
Comparing values
UniqueCustomFieldValue compares stored forms. It asks the field type for equivalentValues(), the list of stored forms that count as the same value, and rejects a value when another record holds any of them.
For a link, that list is the normalized value plus the value with https://, with http://, and with no scheme. A stored https://acme.com/pricing blocks a typed acme.com/pricing.
Normalize a value the same way before you compare it in your own code:
use Relaticle\CustomFields\Facades\CustomFieldsType;
$stored = CustomFieldsType::getFieldTypeInstance($field->type)->normalize($typed, $field);
Reading and querying
getCustomFieldValue() returns the value of the typed column, decrypted when the field is encrypted. Call the withCustomFieldValues() scope first when you read many records, so the values load in one query.
To filter, query the typed column of the field. CustomField::getValueColumn() returns its name.
// A scalar field
Company::whereHas('customFieldValues', fn ($query) => $query
->where('custom_field_id', $revenueField->getKey())
->where($revenueField->getValueColumn(), '>=', 1_000_000));
// A multi-choice field: compare against the stored form
Company::whereHas('customFieldValues', fn ($query) => $query
->where('custom_field_id', $websiteField->getKey())
->whereJsonContains('json_value', 'https://example.com/pricing'));
An encrypted field cannot be filtered or sorted in SQL, because the column holds ciphertext.
Values stored by an older version
Normalization on every write path starts in 3.13. The package rewrites no existing row, so a value saved earlier keeps its old spelling until it is saved again. The 3.13 upgrade notes describe how to normalize old rows.
Design Philosophy
Type-Safe Flexibility
The schema uses multiple typed columns in custom_field_values rather than a single text column. This eliminates costly type conversions, enables native database sorting/filtering, and maintains data integrity through database-level constraints. When you store an integer, it's actually stored as an integer—not a string that needs parsing.
Hierarchical Organization
Fields are organized into sections, providing logical grouping essential for complex forms. This two-level hierarchy supports progressive disclosure in UIs and administrative organization without adding complexity to simple use cases.
Performance-First Indexing
Strategic composite indexes optimize the most common query patterns: entity lookup, field discovery, and polymorphic joins. The schema is designed for the queries you'll actually run, not theoretical completeness.
Why This Schema Design
Polymorphic Flexibility: Any model can have custom fields without tight coupling or migration dependencies. Add custom fields to Product, User, Order—anything implementing the HasCustomFields interface.
Multi-Tenant Isolation: Optional tenant awareness is built into the core schema, not bolted on later. When enabled, all data is automatically isolated between tenants while maintaining query performance.
Extensible Field Types: Field types are pluggable through a clean interface. The settings JSON column provides unlimited extension points without schema changes.
Efficient Querying: Unlike traditional EAV models, this design supports efficient filtering and sorting on custom field values using native database types and proper indexing strategies.
Performance Considerations
This schema excels with complex forms, multi-tenant applications, and admin interfaces requiring dynamic field management. The typed storage and strategic indexing make it suitable for production applications with significant data volumes.
Consider the performance implications for sparse data (many NULL values) and plan custom queries for complex cross-field reporting needs. The architecture prioritizes the common case: efficient field definition, value storage/retrieval, and entity-centric queries.
Multi-Tenancy Support
When enabled, tenant_id is included in all unique constraints and automatically filtered through model scopes. This ensures complete data isolation while maintaining query performance through proper indexing.