Skip to content

Custom fields

Staff define typed fields in the admin and attach them to records without a migration. The module is app-modules/custom-fields/, namespace WebMoves\CustomFields, and it knows nothing of sites or the catalog. Other modules plug in. See app-modules/custom-fields/ARCHITECTURE.md for the full reference, and custom fields for the staff view.

CustomFieldSet a named bundle for one model class ("Garment")
CustomFieldGroup a visual section of a set, with a placement
CustomField a typed definition, joined to groups by a pivot with a weight
value rows per record, in six typed tables

A record points at no more than one set through custom_field_set_id.

Class Key points
CustomFieldSet customizable_type (class name, locked after creation), is_default, weight. Optional handle for code to find it, so staff can rename it: CustomFieldSet::forHandle($modelClass, $handle)
CustomFieldGroup Belongs to one set. area is a FieldPlacement: top, left_sidebar, main, right_sidebar, bottom
CustomField customizable_type, key, label, field_type, default_value, required, available_options, config_options. field_type and customizable_type are locked after creation
CustomFieldGroupCustomField The pivot. Carries weight

Fields are reusable across a type’s sets, while groups belong to one set.

Invariants, enforced by observers, with tests in place of database constraints:

  • A field and its group’s set have the same customizable_type.
  • A field appears in at most one group per set.
  • A customizable_type must be registered.
  • At most one default set per type. This one is a database constraint, a unique index on a virtual default_marker column. Marking a new default demotes the old one on save.

There is one value table per primitive, so nothing is coerced through a text column:

DataType Table
STRING custom_field_string_values
TEXT custom_field_text_values
INT custom_field_int_values
DECIMAL custom_field_decimal_values
DATETIME custom_field_datetime_values
BOOLEAN custom_field_boolean_values

Each row has custom_field_id, customizable_type, customizable_id and value. customizable_type here is the record’s morph class (product), unlike the class name stored on sets and fields. DataType::valueClass() and relationshipName() map a type to its model and relation. A multi-value field stores one row per value, deleted and reinserted on save.

Switching a record’s set is non-destructive. Fields outside the set stop rendering and being written, but their rows stay and come back when the set does. Value rows go only when the record is force-deleted or the field is deleted.

A Contracts\FieldType says how a field behaves everywhere: its data type, form input, table column, filter, how it reads to a person, and which models may use it. Extend FieldTypes\AbstractFieldType:

class StringFieldType extends AbstractFieldType
{
protected DataType $dataType = DataType::STRING;
public function makeFormComponent(string $name, ?array $options = null): Component
{
return TextInput::make($name);
}
public function makeOptionsBuilderComponent(): ?Component
{
return null;
}
}

Types offered in the admin are those listed in field_types in the root config/custom-fields.php. A class with no entry there is not available, even if it exists in FieldTypes/.

getAllowedModels() and supportsModel() restrict a type to some models. The field editor filters by them at creation.

A type with storesOwnValue() true keeps its value outside the value tables. ImageFieldType does: the value is a media item in the record’s custom-field-images collection, tagged with the field’s id. Its conversions come from custom-fields.image_sizes, and it is the same on every site. Only HasMedia models support it. Read it with:

$record->customFieldImageUrl('headshot', 'medium');

Every content type and taxonomy already has them through its base class. For any other model:

  1. Trait. use WebMoves\CustomFields\Traits\HasCustomFields;
  2. Column. Add a nullable custom_field_set_id foreign key to custom_field_sets, null on delete.
  3. Register. Add to customizable_models in the root config/custom-fields.php, with a label and group, or call CustomFields::registerCustomizableModel(). CmsServiceProvider does the latter for registered types at boot, unless config already lists them.
  4. Filament. On the resource, use Filament\HasCustomFieldsInBorderLayout for the picker and groups in a border layout. For another layout, compose Filament\HasCustomFieldSupport directly and call makeCustomFieldSetPicker() and getCustomFieldSchemaForPlacement(). Both are safe no-ops on a model without the trait. View pages use HasCustomFieldsInInfolistLayout, as sales’ CustomerResource does.

A field’s model attribute is its key with the custom_ prefix (CustomField::getAttributeName()):

$page->custom_role; // read, loading values on first access
$page->custom_role = 'Director'; // staged, written on save
$page->save();

The theme’s team-member.blade.php reads values this way.

How a save works, in Traits\HasCustomFields:

  • saving assigns the type’s default set if the record has none, then stages custom_* values that belong to the current set. Others are silently dropped.
  • saved writes staged values to the typed tables.
  • A field not touched in this save keeps its value; one explicitly set to null is cleared.
  • Hard delete removes value rows. Soft delete keeps them for restore().

isFillable() lets any custom_* attribute through mass assignment, because the set-scoped staging is the real gatekeeper. custom_field_set_id is not mass assignable; set it directly.

Query with whereHas on the typed relation or a semi-join on the value table, since custom fields are not columns.

Another module can add a section to the set form and store what it collects, with no dependency pointing at it: implement Contracts\CustomFieldSetFormExtension (key(), components(), fill(), save()) and call CustomFields::registerSetFormExtension(). Content-management’s per-site template picker is one (FieldSetTemplateExtension), which is how a field set can choose a template.

Catalog’s category filter sidebar uses custom fields as facets. Three field types in app-modules/catalog/src/FieldTypes/ implement the marker Contracts\ProductFacetFieldType:

Type Key behaviour
ProductFilterCheckboxType Multi-select, drawn as checkboxes
ProductFilterRadioGroupFieldType One choice, drawn as radios
ProductFilterColorChoiceFieldType Drawn as colour swatches

The marker is all it takes. LayeredNav\ProductFacetFields discovers facets by field type, not by a group name or a flag on the field, and custom fields carry no facet-specific data. Options live in available_options as label, a token value and, for colours, hex_color. Selections are one row per token in custom_field_string_values. URLs carry f[<field key>][]=<slug>, the slug derived from the label, so renaming a label changes its URL.

Faceted queries filter by semi-joins on the value table, so this is where catalog filtering performance lives rather than site_documents. custom_field_string_values has covering indexes for it: cfsv_field_value_owner_idx for filtering, cfsv_owner_field_value_idx for counting. The other value tables have single-column indexes only, so a facet on another data type would need the same pair.

Colours, sizes and other options that make variants are never custom fields. Catalog option types cover those. See catalog for the rest of the facet engine and filters for the staff view.