Skip to content

Custom Ticket Fields Developer Guide

Ed Mozley edited this page Oct 18, 2026 · 1 revision

πŸ› οΈ Custom ticket fields - Developer Guide

How custom ticket fields (discussion #179) work underneath. For what they do, see Custom ticket fields.


1. πŸ“ The files

File What it does
includes/services/ticket_fields.php TicketFieldsService - which fields a ticket has, validation, writing with history, the closing check, and designing fields
includes/typed_fields.php TypedFields - the shared engine: types, coercion, required rule, value storage. Shared with the CMDB and custom asset fields
includes/services/tickets.php createTicket() / updateTicket() take custom_fields
includes/services/mandatory_fields.php missing() adds custom fields required at closing
includes/self_service_ticket_types.php The portal's types and company, shared by its form and its create
assets/js/ticket-fields.js + assets/css/ticket-fields.css One renderer for the New ticket form, the ticket panel and the portal
api/tickets/get_ticket_custom_fields.php / save_ticket_custom_fields.php The inbox's read and per-field save
api/tickets/get_ticket_fields_settings.php, save_ticket_field.php, delete_ticket_field.php, reorder_ticket_fields.php The settings tab
api/self-service/get_ticket_fields.php The portal form: types (when they matter) and asked fields
api/self-service/create_ticket.php The portal create: type and answers re-checked
api/v1/resources/tickets.php custom_fields on GET / POST / PATCH
tests/ticket-custom-fields.php The test (section 9)

2. πŸ—„οΈ The four tables

Table Holds
ticket_fields the definitions: field_key (immutable), label, field_type, config JSON, help_text, all_types, required_on_create, required_on_close, ask_requester, display_order, tenant_id, is_deleted
ticket_field_options dropdown choices; the answer stores the option's text
ticket_field_types which types a field is on when all_types = 0
ticket_field_values the answers, one typed column each - the same shape as asset_field_values
  • Soft delete only. ticket_field_values.field_id is a RESTRICT foreign key in database/freeitsm.sql, so a field with answers can only be retired.
  • seq is always 0, reserved so a multi-value field needs no index change later (Database Verification restores columns, not a changed unique index).
  • uq_ticket_fields_tenant_key does not dedupe shared keys (MySQL treats NULLs as distinct), so freeKey() checks in code - the trap asset_fields documents.
  • No tenant_id on values - inherited from the ticket.
  • ref (link fields) is not offered on tickets: it needs a picker. TicketFieldsService::TYPES is the list.

3. πŸ”‘ Which fields a ticket has

Resolved in one place, TicketFieldsService::fieldsFor($tenantId, $typeId, $audience):

not retired and (shared or the ticket's company's own) and (on every type or linked to the ticket's type) and, for 'requester', ask_requester = 1.

Everything else asks it: the New ticket form, the panel, the portal, the API, the closing rule. Two copies of this rule would be the bug.

πŸ”‘ Type changes hide, never delete. TypedFields::readValues() only returns values for the definitions it is handed, so an answer to a field the new type lacks is simply not read. Nothing removes it.


4. βœ… The two required rules

toDef() sets the engine's required to required_on_create, so TypedFields::checkRequired() enforces it with the CMDB's asymmetry: missing altogether is refused on create; on update only fields being written are checked - so a required field cannot be cleared, but editing another field on an older ticket never fails because of it.

Logging - only the channels that can ask:

Channel custom_fields passed? Enforced?
New ticket form (api/tickets/create_ticket.php) yes, always βœ…
REST API POST /tickets yes - defaulted to [] βœ…
Portal (api/self-service/create_ticket.php) yes, requester fields only βœ… for asked fields
Morning checks' raise-a-ticket (same endpoint, no custom_fields) no ❌
Email, chat, workflows, project and domain alerts no ❌

πŸ”‘ createTicket() enforces only when the custom_fields key is present. Present means "this channel can ask". That is the switch - an alert raised by the system must never be refused because nobody filled in a form it never showed.

Closing - MandatoryFieldsService::missing($conn, $tenant, $row, $pendingCustom) appends TicketFieldsService::missingOnClose() as cf:<key> => label, even when no built-in field is ticked, so the tab's mode (warn / notify / block) and its note and email cover custom fields too. $pendingCustom is the answers being written by the same updateTicket(), so filling a field in the save that closes the ticket counts. The inbox's pre-close check (get_mandatory_fields_check.php) gets the label through t()'s fallback to English - the name an admin typed.


5. ✍️ Writing

  • Validate first, write later. validate() runs unknown-key, required, email / URL format, length and every TypedFields::coerce() rule before anything is written. In createTicket() and the portal create it runs before the INSERT, so a bad answer never leaves a half-made ticket.
  • updateTicket() resolves the definitions against rowAfterUpdates() - a type changed in the same request decides which fields apply - and writes after the UPDATE. A save with only custom fields still touches updated_datetime.
  • History is always server-side. write() adds one ticket_audit row per changed field, field_name = the label (cut to 100), values as a person reads them (displayValue(), cut to 500), whatever $writeAudit says - the UI never logs these client-side. A portal answer is audited with a NULL analyst.

6. πŸ–₯️ The screens

assets/js/ticket-fields.js exposes TicketFields.render(fields, opts), collect(container) and firstMissing(container, fields).

  • variant: 'toolbar' - the panel. Rendered into #ticketCustomFields (display: contents) inside .ticket-toolbar, so each field is a real grid item beside the built-in ones and the phone layout's restyling of the toolbar applies unchanged.
  • variant: 'form' - the New ticket forms, with the form's own classes (inputClass / selectClass / labelClass) so a custom field is indistinguishable from the built-in ones.
  • collect() sends an empty control as '' - clear - never omits it.
  • Yes / no has three states, Not set included, exactly as the asset renderer.
  • The panel reloads its fields after a type change (assignTicketType()); the New ticket form after a type or company change, keeping answers already typed.

7. 🌐 The portal

selfServiceTicketTypeMatters() - the picker appears only when an asked field is on particular types; otherwise choosing a type would change nothing. Types offered and types accepted come from one function, selfServiceTicketTypes(), so a hand-posted type the company does not offer is dropped, and a hand-posted key for an analyst-only field is a refusal (fieldsFor(..., 'requester') does not contain it). A chosen type also numbers the ticket per type, as other channels do.


8. πŸ” Designing fields: scope

designScope() copies ticket types: working in the default company (or on a one-company install) manages shared fields; working in a client company manages that company's own, and sees shared ones read-only. loadEditable() enforces it on every save, retire and reorder, and a field may only be linked to types that context can see (assertTypeUsable()). The capability is Cap::TICKETS_CUSTOM_FIELDS.


9. πŸ§ͺ The test

php tests/ticket-custom-fields.php - 29 checks on the real tables with throwaway ZZTF types, fields and one ticket inserted directly (no workflow, email or notification fires), removed at the end:

  • which fields a type has, and what the requester is asked;
  • required-when-logging refusals, including createTicket() refusing before any row is written;
  • dropdown, email and unknown-key refusals;
  • answers stored, shown, audited; a required one cannot be cleared;
  • a type change hides an answer, the row stays, changing back restores it;
  • missing at closing, and not missing when the same save fills it in;
  • the kind lock, a second field with the same name getting _2, retiring keeping answers;
  • the portal's type picker rule.

Checked in a browser as well: the settings tab and dialog, the New ticket form, the panel, and the portal on desktop and phone - and the portal create refusing a missing answer, an analyst-only key and a bad option without creating a ticket.


10. Not done yet

  • Workflow conditions (engine.php field lists), email merge codes (buildTicketMergeData()), report packs and dashboard widgets, search - each is a hard-coded list that would have to learn cf:<key>.
  • Split and merge do not copy answers to the new ticket.
  • The portal ticket page does not show the requester their answers.
  • Translations: field names and options are data, like ticket type names, and are shown as typed.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally