Appearance
Plugin fields
A plugin can ask the technician for extra information in the field app without any app code. You declare the fields in plugin.json. Core ships the declarations to the app, validates what comes back, stores it and shows it on the console job sheet.
Field app fields
Declare fields under mobile.fields:
json
{
"mobile": {
"entry": null,
"fields": [
{
"key": "docket_number",
"label": "Disposal docket number",
"type": "text",
"entity": "job",
"where": "completion",
"required": true,
"max_length": 80,
"placeholder": "DKT-00000",
"help": "Printed on the docket the facility gives you."
},
{ "key": "volume_litres", "label": "Volume disposed", "type": "number", "where": "completion", "min": 0, "max": 100000, "unit": "L" },
{ "key": "docket_photo", "label": "Photo of the docket", "type": "photo", "where": "completion" },
{ "key": "gate_code", "label": "Gate code", "type": "text", "entity": "site" },
{ "key": "sludge_depth_cm", "label": "Sludge depth", "type": "number", "entity": "asset", "min": 0, "max": 500, "unit": "cm", "job_types": ["pump_out"] }
]
}
}| Property | Meaning |
|---|---|
key | snake_case, unique per entity. |
label | What the technician sees. |
type | text, textarea, number, boolean, select, date or photo. |
entity | What the value belongs to: job (default), site (the property) or asset (the onsite system). |
where | job: on the job sheet at any time (default). completion: in the "Before you complete" section. |
required | Must be filled before the job can be completed. Applies to the job's own fields and to its site and asset. |
options | For select: a list of strings or { "value", "label" } objects. |
max_length, min, max, unit, placeholder, help | Type-specific limits and hints. |
job_types | Limit the field to some job types, for example ["pump_out"]. |
A bad declaration (unknown type, repeated key, a select without options) makes the plugin fail to load with a message that names the field.
Types in the app
text,textarea: text box.max_lengthdefaults to 255 and 4000.number: decimal keypad, with the unit beside it.minandmaxare checked.boolean: Yes and No choices. Tapping the chosen answer again clears it. "No" counts as an answer forrequired.select: one button per option.date:YYYY-MM-DD, with a Today shortcut.photo: camera or library. The photo goes through the normal photo upload queue, filed under the field keyplugin:<slug>:<key>. The value is the app's local photo id.
An app version that does not know a type shows the value as read-only text and never blocks a job on it.
Changing the declaration per organization
The mobile.fields filter receives a plugin's declared fields (normalised) and its slug. The bundled disposal-dockets plugin uses it to make the docket number required only when the organization's setting says so:
php
$api->addFilter('mobile.fields', function (array $fields, string $slug): array {
return array_map(function (array $field): array {
if ($field['key'] === 'docket_number') {
$field['required'] = (bool) $api->setting('require_on_completion', true);
}
return $field;
}, $fields);
}, 10, 2);How values sync
Pull. The mobile sync pull returns, for each plugin the organization has enabled and that declares fields:
json
"extensions": {
"disposal-dockets": {
"name": "Disposal docket numbers",
"version": "1.1.0",
"fields": [ { "key": "docket_number", "type": "text", "entity": "job", "where": "completion", "required": true } ],
"values": { "job": { "<job id>": { "docket_number": "DKT-20407" } } }
}
}values covers the jobs, properties and systems on the device. The mobile.pull.payload filter still runs afterwards.
Push. The app queues a plugin_fields_saved event whenever the technician changes a field. Edits to the same record before a sync are folded into one event:
json
{
"event_type": "plugin_fields_saved",
"occurred_at": "2026-11-12T01:20:00Z",
"payload": {
"plugin": "disposal-dockets",
"entity": "job",
"entity_id": "<job id>",
"values": { "docket_number": "DKT-31337", "volume_litres": 3500 }
}
}Core then:
- validates every value against the declaration (type, range, options, length). A bad value fails validation with
events.N.payload.values.<key>; - stores it in
plugin_field_values(organization, plugin, record type, record id, key). The record must belong to the organization, or the event is ignored; - keeps the newest capture: an older
occurred_atnever replaces a newer one, and anullvalue clears the field; - ignores events for a plugin the organization has not enabled (result status
ignored), and drops keys the plugin no longer declares (listed underdroppedin the result). Neither blocks the sync; - blocks
job_status_changedtocompletedorcompleted_pending_reviewwith a validation error while a required field is empty. Values sent earlier in the same batch count.
Replays are safe: events are idempotent on idempotency_key.
Hooks
| Hook | Use |
|---|---|
mobile.fields (filter) | Change declared fields per organization. |
mobile.field_values (filter) | See values before they are saved. Throw a ValidationException to veto. |
mobile.fields_saved (action) | Values were saved. Use it to copy them into the plugin's own tables. |
disposal-dockets listens to mobile.fields_saved and keeps its disposal_dockets table in step, so its PDF block and route keep working.
Where values show
Saved values for a job appear on the console job sheet as extra facts, labelled with the field's label, for enabled plugins only. Disabling a plugin hides its fields and values and keeps the data.
What happens when things change
- A plugin is switched off: the next pull no longer lists it. The app stops showing its fields. Values waiting to send are sent anyway and ignored by the server.
- A field is removed or its type or options change: before each push the app drops waiting values that no longer fit the declaration, instead of failing the whole sync.
- Unsent values survive a pull. A value the technician captured is never replaced by the server's older copy until it has been sent.
- An unknown field type is shown read-only.
Themes in the field app
The pull also carries theme: the organization's resolved tokens and the logo address. The app applies brand, frame, ink, canvas, surface, line and radius, saves them on the device, and checks contrast. A colour that would make text unreadable is darkened a little, or ignored and the app keeps its own.