Fields
These are the built-in non-relation fields.
Most fields store their value in a model attribute with the same name as the field. Use setColumn() when the database column differs from the field name.
Use the storage note in each section as the starting point for migrations and model casts. Relationship fields have their own storage rules in Relation Fields.
TextField
Default string field. It adds a string rule, LIKE filter and column sorter.
Storage: use a string column for short values or text when the value can be longer. No cast is needed.
TextField::make('title')
->setRules(fn () => ['required', 'string', 'max:255']);
TextAreaField
Extends TextField and renders a textarea.
Storage: usually a text column. No cast is needed.
setAutoSize(bool|array $autoSize): static
TextAreaField::make('description')
->setAutoSize(['minRows' => 3, 'maxRows' => 8]);
EmailField
Extends TextField, uses the Email frontend component and adds a default email rule.
Storage: use a string column. No cast is needed.
PasswordField
Extends TextField, hashes the submitted value and skips empty values.
Storage: use a string column. No cast is needed.
PasswordField::make('password')
->setRules(fn () => ['required', Password::default()])
->setIsOnlyShownOnCreate();
NumberField
Numeric field with formatting options.
Storage: use the numeric column that matches the domain value, such as integer, decimal or float. Add a Laravel numeric cast when the model should return a typed value.
setPrecision(int $precision): staticsetIsCurrency(bool $isCurrency = true, ?CurrencyType $currency = null): static
NumberField::make('price')
->setPrecision(2)
->setIsCurrency(currency: CurrencyType::EUR);
CheckboxField
Boolean field. It mutates missing payload values to false, filters by boolean and sorts by column.
Storage: use a boolean column and a boolean cast.
setIconChecked(string $icon): staticsetIconUnchecked(string $icon): staticsetIconSize(int $size): staticsetClassChecked(string $class): staticsetClassUnchecked(string $class): static
SelectField
Choice field. Options can be sent immediately or loaded on demand through the field's node response lifecycle.
Storage: use a string, integer or enum-backed column for one value. When setAllowMultiple() is enabled, use a json column and an array cast.
setOptions(array|Closure(SelectMetadata): array $options, bool $addRules = true): staticsetOptionsFromEnum(string $enumClass, bool $addRules = true): staticsetMutateOptionsCallback(Closure(Form): array $callback): staticsetShouldLoadOptionsImmediately(bool $value = true): staticsetAllowMultiple(bool $value = true): staticsetIsFrontendFilteringEnabled(bool $value = true): staticsetDisplayAsTags(bool|Closure(): array<string|int, TagNode> $value = true): staticsetHasAddButton(bool $value = true): staticsetAddButtonFormEndpoint(?string $endpoint): staticsetAddFormModalWidth(int $value): staticonAddFormReady(Closure(Form): void $callback): static
SelectField::make('status')
->setOptions([
new SelectOption('draft', 'Draft'),
new SelectOption('published', 'Published'),
])
->setDisplayAsTags();
Calling setDisplayAsTags() without arguments makes the detail display render the selected value as a default TagNode.
Pass a callback when each option value needs its own tag label, color, icon or variant. The callback must return an array where:
- the array key is the option value;
- the array value is a
TagNode; - every key must be a string or integer.
use Qore\Next\System\Node\ColorType;
use Qore\Next\System\Nodes\TagNode;
SelectField::make('status')
->setOptions([
new SelectOption('draft', 'Draft'),
new SelectOption('published', 'Published'),
new SelectOption('archived', 'Archived'),
])
->setDisplayAsTags(fn (): array => [
'draft' => new TagNode(__('common.status.draft'), ColorType::DEFAULT),
'published' => new TagNode(__('common.status.published'), ColorType::GREEN),
'archived' => new TagNode(__('common.status.archived'), ColorType::RED),
]);
The tag map is separate from the select options. Options control what the user can choose; the tag map controls how a selected value is displayed. If the selected value is not present in the tag map, Qore falls back to a simple tag using the raw selected value as the title.
Multiple selected values must be stored as an array. Use a json column and an array cast on the model:
SelectField::make('labels')
->setOptions([
new SelectOption('bug', 'Bug'),
new SelectOption('feature', 'Feature'),
new SelectOption('support', 'Support'),
])
->setAllowMultiple()
->setDisplayAsTags();
protected function casts(): array
{
return [
'labels' => 'array',
];
}
Lazy options:
SelectField::make('customer_id')
->setShouldLoadOptionsImmediately(false)
->setOptions(function (SelectMetadata $metadata) {
return Customer::query()
->where('name', 'like', "%{$metadata->getSearch()}%")
->limit(20)
->get()
->map(fn (Customer $customer) => new SelectOption($customer->id, $customer->name))
->all();
})
->setMutateOptionsCallback(function (Form $form) {
return Customer::query()
->whereKey($form->getFieldValue('customer_id'))
->get()
->map(fn (Customer $customer) => new SelectOption($customer->id, $customer->name))
->all();
});
Use setMutateOptionsCallback() when another field or edit value can set a selected value while options are not loaded immediately.
DateField
Date/datetime field with validation, filtering and timezone support.
Storage: without time, use a date column and a date or immutable_date cast. With time, use a dateTime or timestamp column and a datetime or immutable_datetime cast.
setHasTime(bool $hasTime): staticsetMinuteStep(int $minuteStep): staticsetMinDate(CarbonInterface|Closure(Form): ?CarbonInterface|null $minDate): staticsetMaxDate(CarbonInterface|Closure(Form): ?CarbonInterface|null $maxDate): staticsetIsFutureOnly(bool $value = true): staticsetDisabledDates(array|Closure(Form): array $disabledDates): staticsetShouldApplyTimezoneOnDisplay(bool $value): staticsetShouldApplyTimezoneOnMutate(bool $value): static
CreatedAtField and UpdatedAtField extend DateField, enable time and hide themselves on forms.
TimeField
Time input.
Storage: use a string or database time column. No cast is needed unless the application wants to expose a custom value object.
setMinuteStep(int $minuteStep): staticsetDisabledHours(array $disabledHours): static
FileField
File upload field. It is also relational because files are stored through the Qore file model.
Storage: no normal parent-table column is needed. The field stores file relations through the Qore file model.
The model that owns the field must implement ModelWithFiles and use HasFiles:
use Illuminate\Database\Eloquent\Model;
use Qore\Next\System\Model\HasFiles;
use Qore\Next\System\Model\ModelWithFiles;
class Project extends Model implements ModelWithFiles
{
use HasFiles;
}
Whether a file can be uploaded or viewed depends on your FilePolicy.
File uploads are authorized through app/Policies/FilePolicy.php. The upload() method receives the temporary File, the related model when there is one, and the related resource. If uploads fail even though the field configuration looks correct, check that policy first.
setMaxFileSize(int $maxUploadSize): staticsetMimeTypes(array $mimeTypes): staticsetMinFiles(?int $minFiles): staticsetMaxFiles(int $maxFiles): staticsetPreviewImageWidth(int $previewImageWidth): static
FileField::make('attachments')
->setMaxFiles(5)
->setMimeTypes(FileMimeType::documents());
AddressField
Address value field.
Storage: use a json column and cast it to Qore\Next\System\Address\Address::class.
setDriver(AddressDriver $driver): static
If no driver is specified, it will use the free Dutch PdokAddressDriver by default.
AddressField::make('address', 'Address')
// Or specify a driver:
->setDriver(new GooglePlacesAddressDriver(includedRegionCodes: ['nl', 'de']));
Migration:
Schema::table('customers', function (Blueprint $table) {
$table->json('address')->nullable()->after('name');
});
Model cast:
use Qore\Next\System\Address\Address;
protected function casts(): array
{
return [
'address' => Address::class,
];
}
PhoneField
Phone number field.
Storage: use a string column. No cast is needed.
setDefaultCountryCode(PhoneCountryCode $code): static
WebsiteField
Website field.
Storage: use a string column. No cast is needed.
setIsDomainOnly(bool $value = true): static
WysiwygField
Rich text field. You must define a purifier before saving HTML.
Storage: use a text or longText column. No cast is needed.
setPurifier(Closure(string, Model): string $purifier): staticsetConfig(array $config): static
WysiwygField::make('body')
->setPurifier(fn (string $value) => $this->myCleanFunction($value));
RepeaterField
Repeats a nested set of fields inside a parent form.
Use it when the user needs to add, edit and remove small child records while they are still filling out the parent form: contacts on a customer, milestones on a project, invoice lines on an invoice or checklist items on a task.
Storage: no parent-table column is used by default. Rows are stored as TemporaryEntity records until the parent model is saved. Persist the final rows through the model/table that belongs to your domain.
setFields(Closure(Form): array $callback): staticsetEntityDisplayNode(?string $name): static
RepeaterField lifecycle, default UI and customization
TemporaryEntity is a temporary transport model, not persistent domain storage. If repeater rows should survive as real application data, add your own lazy mutator and save them into your own model/table after the parent model exists.
How It Works
Each repeater row is edited in its own nested FormNode.
When the row form is opened, RepeaterField calls setFields() and returns a form for only that row. When that row form is submitted, Qore validates the nested fields and stores the row as a TemporaryEntity:
uuidis the stable frontend row key;nameis the repeater field name;payloadcontains the nested field values;delete_atis set while the row is not attached to a parent model;entity_typeandentity_idare filled when the parent model is saved.
When the parent form is saved, the repeater's lazy mutator compares the submitted row UUIDs with the rows already attached to the parent model. Submitted rows are updated and attached to the parent. Missing rows are deleted.
Default Frontend Component
RepeaterField does not override fieldComponent(), so the base field class removes the Field suffix and serializes the frontend field component as Repeater.
The framework registers that component in resources/js/fields.ts:
registerField({
name: 'Repeater',
detail: RepeaterDetail,
form: RepeaterField
})
The default form component is components/fields/repeater/RepeaterField.tsx.
It renders:
- one card per temporary entity;
- an add button;
- a delete button per row;
- an Ant Design modal for adding or editing a row;
- a nested
<Node>that loads the row form from the current form endpoint withrepeater_fieldand optionalentity_uuidquery parameters.
On row form success, the default component updates the local repeater value and writes it back to the parent form through useFieldForm().
Basic Example
use Qore\Next\System\Fields\EmailField;
use Qore\Next\System\Fields\RepeaterField;
use Qore\Next\System\Fields\TextField;
use Qore\Next\System\Form\Form;
RepeaterField::make('contacts', __('common.contacts'))
->setRules(fn (): array => ['array', 'min:1', 'max:5'])
->setFields(function (Form $form): array {
return [
TextField::make('name', __('common.name'))
->setRules(fn (): array => ['required', 'string', 'max:255']),
EmailField::make('email', __('common.email'))
->setRules(fn (): array => ['nullable', 'email']),
];
});
The min and max validation rules are also serialized to the frontend as min_count and max_count. The add and delete buttons use those values to prevent obvious invalid row counts before submit.
Persisting Rows As Real Models
The repeater value submitted with the parent form is an array of temporary rows. Add a lazy mutator when those rows should become real child models after the parent has been saved.
use App\Models\Customer;
use Illuminate\Database\Eloquent\Model;
use Qore\Next\System\Fields\RepeaterField;
RepeaterField::make('contacts', __('common.contacts'))
->setFields(fn (Form $form): array => [
TextField::make('name')->setRules(fn (): array => ['required', 'string']),
EmailField::make('email')->setRules(fn (): array => ['nullable', 'email']),
])
->setMutator(
mutateCallback: function (): void {
//
},
mutateLazyCallback: function (array $payload, Model $model): void {
/** @var Customer $model */
$rows = $payload['contacts'] ?? [];
if (! is_array($rows)) {
return;
}
$submittedUuids = [];
foreach ($rows as $row) {
if (! isset($row['uuid'], $row['payload']) || ! is_array($row['payload'])) {
continue;
}
$submittedUuids[] = $row['uuid'];
$model->contacts()->updateOrCreate(
['temporary_uuid' => $row['uuid']],
[
'name' => $row['payload']['name'] ?? null,
'email' => $row['payload']['email'] ?? null,
],
);
}
$model->contacts()
->whereNotIn('temporary_uuid', $submittedUuids)
->delete();
},
);
Use a normal mutateCallback when the parent model can be changed before save. Use mutateLazyCallback for repeater persistence because child models usually need the saved parent model key.
Files Inside Repeaters
Nested FileFields work because TemporaryEntity implements ModelWithFiles.
When a row form submits, RepeaterField asks nested FileFields to lazily attach uploaded files to the temporary row. When you later persist the row into your own child model, copy or re-associate those files as part of your lazy mutator if the files should belong to the real child model instead of the temporary row.
Custom Row Display
By default, the frontend displays a repeater row as JSON of its payload.
Use setEntityDisplayNode() when the default add/edit/delete UI is fine, but each row card needs a nicer summary:
RepeaterField::make('contacts', __('common.contacts'))
->setEntityDisplayNode('ContactRepeaterRow')
->setFields(fn (Form $form): array => [
TextField::make('name'),
EmailField::make('email'),
]);
Register a frontend node with the same name:
import { registerNode, RepeaterRowProps } from 'qore-next'
import { Typography } from 'antd'
interface ContactPayload {
name?: string
email?: string
}
function ContactRepeaterRow({ entity }: RepeaterRowProps<ContactPayload>) {
return (
<div>
<Typography.Text strong>
{entity.payload.name || 'Unnamed contact'}
</Typography.Text>
{entity.payload.email && (
<Typography.Text type='secondary' className='block'>
{entity.payload.email}
</Typography.Text>
)}
</div>
)
}
registerNode({
name: 'ContactRepeaterRow',
node: ContactRepeaterRow
})
The component receives the field and the temporary entity. It is meant for display only; row editing still happens through the nested form.
Custom Repeater Component
Create a custom field component when the default repeater UI itself is not enough. For example, use this when rows should be draggable, rendered as a dense grid or edited inline instead of in a modal.
On the backend, extend RepeaterField and return a new field component name:
namespace App\Fields;
use Qore\Next\System\Fields\RepeaterField;
class ContactRepeaterField extends RepeaterField
{
public function fieldComponent(): string
{
return 'ContactRepeater';
}
}
On the frontend, register a field with that name:
import { Button, Flex } from 'antd'
import {
FieldFormProps,
registerField,
TemporaryEntity,
useFieldForm
} from 'qore-next'
interface ContactPayload {
name?: string
email?: string
}
interface ContactRepeaterProps
extends FieldFormProps<TemporaryEntity<ContactPayload>[]> {
min_count?: number | null
max_count?: number | null
}
function ContactRepeaterField(field: ContactRepeaterProps) {
const { setValue } = useFieldForm(field)
const rows = field.value ?? []
return (
<Flex vertical gap='small'>
{rows.map((row) => (
<div key={row.uuid}>{row.payload.name}</div>
))}
<Button
disabled={
field.max_count !== null &&
field.max_count !== undefined &&
rows.length >= field.max_count
}
onClick={() => {
setValue(rows)
}}
>
Add contact
</Button>
</Flex>
)
}
registerField({
name: 'ContactRepeater',
form: ContactRepeaterField
})
The custom component receives the same serialized values as the default component, including entity_display_node, min_count and max_count. If you replace the default modal flow, make sure your component still writes an array of temporary entities back to the parent form.
When To Use A Relation Field Instead
Use RepeaterField when the child rows are edited inline as part of the parent form and do not need their own resource page.
Use a relation field with a table when the child records deserve their own CRUD flow, permissions, filters, actions or detail pages.
NodeField
Renders a node (e.g. a ButtonNode or ProgressNode) inside a field layout.
Storage: no column is used unless your custom node writes a value through custom form or action logic.
setNode(Closure(NodeFieldRequest): Node $callback): static
IdField
Extends TextField, defaults to id and is hidden on forms.
Storage: uses the model primary key column.