Verified against Semitexa Ultimate 2026.10.03.1952
Field Types
A field is declared once, and its type decides how that declaration behaves everywhere:
use Semitexa\PlatformUi\Domain\Model\Field\Field;
Field::text('title')->required()->set('max', 160)->searchable()->sortable();
Field::choice('status', [
'draft' => ['label' => 'Draft', 'tone' => 'warning'],
'published' => ['label' => 'Published', 'tone' => 'success'],
])->required()->filterable();
Field::datetime('updatedAt')->sortable()->readOnly();
| The type supplies | For example (email) |
|---|---|
the form control (platform.field props) |
<input type="email" autocomplete="email"> |
| the validation rules | email (checked as the field is edited, through HUG, and again on save) |
| the stored value | trimmed, lowercased, null when empty |
| the grid column | format text |
| the filter | operators contains, eq (a number, date or moment: the range gte / lte, shown as "from" and "to") |
| an inference from a stored column | a varchar named *email* |
bin/semitexa platform-ui:field-types lists every type with these details, and --json gives
the same list to tools and agents. The demo is /ui-playground/components/fields.
Built-in types
| Type | Control | Column | Notes |
|---|---|---|---|
id |
— | mono | the primary key: read-only, never on a form |
text |
input | text | max → maxlength + maxLength rule |
textarea |
textarea | — | hidden from lists by default; rows, max |
slug |
input | mono | slug rule, lowercased |
email |
input:email | text | email rule, lowercased |
url |
input:url | link | url rule: absolute http(s) only |
integer |
input:number | number | integer rule; min, max |
decimal |
input:number | number | number rule with scale; kept as a string, never a float |
boolean |
switch | ✓ / – | yes/no filter |
choice |
segmented (≤ 4) or select | badge | in rule; an option's tone colours its badge; control overrides |
multiChoice |
checkboxes (or multi-select over 8) | — | in rule on every value |
date |
input:date | date | date rule (a real calendar day); stored as Y-m-d |
datetime |
input:datetime-local | date and time | datetime rule; UTC end to end (see below) |
belongsTo |
select | text | the records are data: the caller supplies them as options() |
belongsToMany |
checkboxes (or multi-select) | — | as above |
file, image |
upload | — | the value is the one-time upload ticket; accept, maxBytes |
json |
— | mono | read-only |
An optional select (a belongsTo, or a choice shown as a list) starts with "— None —", so a
chosen value can be cleared.
Time zones
datetime-local carries no zone. A datetime field reads what is typed as UTC, which is the
framework's invariant, and says "In UTC." under the control. The grid shows the stored moment
formatted for the visitor's locale, also in UTC, with the zone named. A zoneless stored value
(2026-05-31 06:39) is read as UTC rather than as the browser's local time.
Rendering a form from fields
{% for field in fields %}
{{ component('platform.field', ui_field_props(field, record[field.name] ?? null)) }}
{% endfor %}
ui_field_props() returns the type's props plus the value, so the rules travel with the field.
In the save action, UiFieldTypes::for($field)->cast($field, $context->values[$field->name] ?? null)
gives the value to store.
The grid contract
UiFieldSet holds a screen's fields. contractUi() is the route contract's ui block:
- columns: in order, with formats and badge variants;
- filters: one for every
filterable()field; - lists:
sortable()andsearchable()give the field lists for the collection block.
The grid renders the ui block instead of guessing columns from field names.
Rules
The types use the built-in rules required, minLength, maxLength, sameAsField, email,
url, integer, number (an optional scale), min, max, slug, date, datetime and in.
date and datetime refuse a day that does not exist (2026-02-30) and words such as tomorrow.
- Empty values pass. Every rule lets an empty value through, so emptiness belongs to
requiredalone. inchecks every value. On a checkbox group or a multi-select it checks each selected value, because what a select shows is not what a crafted request can send.
Your own type
#[AsFieldType]
final class StarsFieldType extends AbstractUiFieldType
{
public function name(): string { return 'stars'; }
public function description(): string { return 'A rating from 1 to 5.'; }
protected function controlProps(UiField $field): array
{
return ['control' => 'segmented', 'options' => [/* 1…5 */]];
}
protected function typeRules(UiField $field): array { return ['integer', ['min', 1], ['max', 5]]; }
}
Use it as Field::of('rating', 'stars'). A name that repeats a built-in or another project type
fails boot; it never shadows the other type silently.