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.
The model
Section titled “The model”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 tablesA 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_typemust be registered. - At most one default set per type. This one is a database constraint, a unique index on a
virtual
default_markercolumn. Marking a new default demotes the old one on save.
Storage
Section titled “Storage”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.
Field types
Section titled “Field types”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.
Types that store their own value
Section titled “Types that store their own value”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');Using custom fields in a module
Section titled “Using custom fields in a module”Every content type and taxonomy already has them through its base class. For any other model:
- Trait.
use WebMoves\CustomFields\Traits\HasCustomFields; - Column. Add a nullable
custom_field_set_idforeign key tocustom_field_sets, null on delete. - Register. Add to
customizable_modelsin the rootconfig/custom-fields.php, with alabelandgroup, or callCustomFields::registerCustomizableModel().CmsServiceProviderdoes the latter for registered types at boot, unless config already lists them. - Filament. On the resource, use
Filament\HasCustomFieldsInBorderLayoutfor the picker and groups in a border layout. For another layout, composeFilament\HasCustomFieldSupportdirectly and callmakeCustomFieldSetPicker()andgetCustomFieldSchemaForPlacement(). Both are safe no-ops on a model without the trait. View pages useHasCustomFieldsInInfolistLayout, as sales’CustomerResourcedoes.
Reading and writing
Section titled “Reading and writing”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:
savingassigns the type’s default set if the record has none, then stagescustom_*values that belong to the current set. Others are silently dropped.savedwrites 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.
Adding to the set form
Section titled “Adding to the set form”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.
Filter fields
Section titled “Filter fields”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.