Skip to content

Custom Fields Companies People Developer Guide

Ed Mozley edited this page Nov 15, 2026 · 1 revision

πŸ› οΈ Custom fields on companies and people - Developer Guide

5.4.0 Β· #279 Β· User guide: Custom fields on companies and people Β· Design and decisions: design Β· Related: Typed fields engine - Developer Guide Β· Custom ticket fields - Developer Guide

Custom fields on two more kinds of record, built as one service over the shared TypedFields engine rather than two copies of the ticket code.


πŸ“ The files

File What
includes/services/record_fields.php RecordFieldsService - everything: which fields a record has, validate, write + history, settings
api/system/custom_fields.php System β†’ Custom fields (GET list, GET a company's answers, POST save / delete / reorder). Administrators
system/custom-fields/index.php, system/help/custom-fields.php The settings page and its help
api/people/person_fields.php A company's people fields without values - for the person editor when adding, or when the company changes
includes/services/companies.php save() / upsert() take custom_fields, an ActorContext and a source; customFields()
api/system/save_tenant.php, system/companies/index.php The Companies dialog
api/tickets/save_user.php, get_person.php, includes/person_editor.php The person editor
api/v1/resources/companies.php, users.php custom_fields in reads and writes
api/v1/lib/openapi_schemas.php, api/v1/spec.json The API reference
tests/record-fields.php 23 checks

One service, two entities

const ENTITIES = [
    'company' => [
        'defs' => 'company_fields', 'options' => 'company_field_options', 'values' => 'company_field_values',
        'owner' => 'tenant_id', 'per_tenant' => false, 'audit' => 'company_audit', ...
    ],
    'person' => [
        'defs' => 'person_fields', 'options' => 'person_field_options', 'values' => 'person_field_values',
        'owner' => 'user_id', 'per_tenant' => true, 'audit' => 'user_audit', ...
    ],
];

Every method takes $entity and reads its tables from here. valueSchema($entity) is the TypedFields descriptor (the same typed value columns as ticket_field_values), so TypedFields::readValues(), writeValues(), checkRequired() and coerce() do the type work exactly as they do for tickets, the CMDB and assets.

Which fields a record has - fieldsFor($conn, $entity, $tenantId): every company field; for a person, the shared ones plus their company's own:

if ($c['per_tenant']) {
    // A person with no company belongs to the Default company, as everywhere.
    $tenantId ??= getDefaultTenantId($conn);
    $where .= " AND (f.tenant_id IS NULL OR f.tenant_id = ?)";
    $params[] = $tenantId;
}

TRAP: a person who moves company. Values are only ever read for the fields that apply now, so the old company's answers are hidden, never deleted - and moving back brings them back. Same rule as a ticket that changes type. Do not "clean up" values on a company change.

Writing: validate first, then the row, then the values

validate() refuses unknown keys, arrays, a bad email or web address, over-long text, every TypedFields rule, and (on a create) a missing required field. CompaniesService::save() calls it before the INSERT / UPDATE, so a bad answer never leaves a half-made company; write() runs after, and records each actual change:

$before = self::values($conn, $entity, $ownerId, $defs);
TypedFields::writeValues($conn, self::valueSchema($entity), $ownerId, $defs, $values);
$after = self::values($conn, $entity, $ownerId, $defs);
// one company_audit / user_audit row per field whose value really changed

company_audit is new (with a source: app or api); people use the existing user_audit.

Required means: must be given when the record is created, and cannot be cleared when that field is written. A create with no custom_fields at all is still a create - save() validates an empty set, so a required company field refuses it.

The person editor and a company change

The editor draws the fields get_person.php returns (forRecord()), with TicketFields.render(). Changing the Company box calls person_fields.php?company_id= and redraws, carrying over anything typed into a field the two companies share. Without that, the editor would send the old company's field, and save_user.php - validating against the new company - would refuse it.

Keys

freeKey() makes the key from the name and checks it against every field of that kind, deleted ones and other companies' included, so an API key is never ambiguous or reused.

Settings rules

  • Administrators only (admin_api_guard.php), as every System page.
  • The kind is locked once a field has answers (type_locked).
  • A people field's company is chosen when it is made and never moved - moving it would hand one company's answers to another's screens.
  • Delete is soft (is_deleted); the values' foreign key to the field is RESTRICT.

REST API

Resource Read Write
/companies custom_fields on list (one batched read), get, create, update, upsert custom_fields on POST, PATCH, upsert (needs an admin, all-companies key, as before)
/users custom_fields on list (one read per company on the page), get custom_fields on POST, PATCH - checked against the company they will be in

How it was verified

tests/record-fields.php (on a test install): designing fields, a company's fields through CompaniesService (unknown key, a choice not offered, a bad email, required on create and on clearing, the history row, the API map), people fields shared and per company, moving company and back, soft delete, and the real save_user.php over HTTP. Control: with the old company service and person save, 11 checks fail. companies-279, api-companies, people-org(-api) and system-page-layout still pass; the settings page, the Companies page and both person-editor pages loaded in headless Chrome with no script errors.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally