Adding Custom Fields

10 views Markdown

Store extra information against a client or a ticket without adding a column.

Overview

Two engines share the name. Client profile fields live per language in users_custom_fields with key/value values; ticket fields in tickets_custom_fields on two axes.

Neither needs a schema change: a definition row, and the value lands in a generic store.

Prerequisites

  • An operator account, or a token with the settings and tickets scopes.
  • Familiarity with hooks: transformation is done with two filters.
  • Installed language codes: one row per language, so two languages are two ids.

Structure

Definition and value are separate; the value row is named field_ plus the id.

TableHoldsKeyed by
users_custom_fieldsLanguage, type, label, options, flags, rankid, one row per language
users_informationsThe value, as textowner_id plus field_{id}
tickets_custom_fieldsThe ticket field and its departmentid; label and type in the _lang twin
tickets_access_groupsThe reply-side grouping and its field idsid; the relation lives on the group

The store is untyped text: multi-choice persists comma joined, an empty answer is an empty string.

Walkthrough

Define the Field

  1. Create the definition once per language, from Settings or the client-fields API.
  2. Choose the type: text, textarea, select, checkbox, radio; choice types take an options list.
  3. Set the visibility flags; they are independent booleans and they compose.
  4. Note the id the row is given. Your code uses the id, not the label.

Read and Write the Value

  1. Write with the info helper, keyed by field_{id}.
  2. Read with the info reader: a name with no row comes back as null, never as a missing key.
  3. The reader memoises per request; pass the third argument to bypass it after a write.
  4. Delete rather than blank when "not answered" is meaningful: only a removed row triggers a fallback.

Transform the Value

  1. Listen on the save filter: once per field_* key, before the write.
  2. Listen on the load filter: on every read, after the rows are collected.
  3. Keep the two symmetric, or ciphertext ends up on the profile screen.
  4. Guard on the field id: both filters fire for every custom field.

Reference

Definition Flags

Five of six are integers holding 0 or 1; status is a string, and treating it alike reads every field as disabled.

status active or inactive; an inactive field appears only in the operator's list.
required An empty answer feeds the completeness gate.
uneditable Visible but not writable; the profile save loop skips these fields.
client_hidden Operator only, and it overrides the others: dropped from sign-up, invoice and required.
invoice Printed on the invoice view; empty values are dropped.
signForm Asked during registration; without it, only reachable from the profile screen.

The Value Helper

coremio/helpers/User.php
// Upsert. $values is a map of name => value; a custom field's name is "field_{id}".
// Returns true when at least one row was written.
public static function AddInfo($owner_id = 0, $values = []);
public static function setInfo($owner_id = 0, $values = []);   // alias of AddInfo

// Read. $names is an array (a comma separated string is also accepted and split).
// Returns a map with EVERY requested name present; a missing row reads as null.
// $noCache = true skips the per-request memo, which you need right after a write.
public static function getInfo($owner_id = 0, $names = [], $noCache = false): array;

// Remove rows entirely. $name is a single name or an array of names.
public static function deleteInfo($owner_id = 0, $name = ''): int|bool;

// Row id of an existing value, 0 when there is none.
public static function isInfo($owner_id = 0, $name = ''): int;

// Reverse lookup: which members hold this value. $first = true returns one row.
public static function findInfo($name = '', $value = '', $first = true): array;

// The completeness gate. Returns [field key => label] for everything still empty;
// an empty array means the account may use the client area.
public static function missing_required_fields($id = 0): array;
User::AddInfo() One row per entry. phone and company_name mirror onto the member record.
User::getInfo() Memoised per request, storing the raw value, so the load filter still runs.
User::missing_required_fields() Admin required fields plus the built-in ones, per language.
Order trap: findInfo() It takes the name first, value second: it searches across members. A member id there returns an empty array.
Value shape All text. A checkbox is comma joined on save; the other four are plain strings.

The Two Value Filters

Both fire by reference and are scoped to field_ keys, so timezone never reaches them.

listener signatures
// Fired from User::AddInfo(), immediately before the insert or update.
// $value      mixed, by reference. What you leave here is what is stored.
// $field_id   int, the definition id parsed out of "field_5".
// $owner_id   int, the member the value belongs to.
Hook::add('filter:custom_field.save_value', 10, function (&$value, $field_id, $owner_id) {
    // ...
});

// Fired from User::getInfo(), after the rows are collected and before they are returned.
// Same three arguments, same order.
Hook::add('filter:custom_field.load_value', 10, function (&$value, $field_id, $owner_id) {
    // ...
});
filter:custom_field.save_value The persistence boundary. Change $value in place; the return is not used.
filter:custom_field.load_value The read boundary: decrypt, format, or inject. By reference; the return is not used.
action:client.profile_updated Fires after a profile save with the member id, the changed columns and the info map. A key means saved, not changed. The return is ignored.

Where the Definitions Are Read

Every client surface applies its own filter, and they deliberately disagree.

SurfaceReaderFilter it applies
Account profileget_custom_fields(), account modelactive, not hidden, member's language, by rank
Registration formget_custom_fields(), sign modelthe same, plus signForm = 1
Invoice viewinvoice_custom_fields()invoice = 1; empty values dropped
Validation of one fieldget_custom_field()the list filter, on one id
Operator surfacesget_custom_fields(), users modellanguage only; hidden included

Ticket Fields and Their Two Axes

One definition table, two composers; the two memberships live in different places.

coremio/helpers/tickets.php
// Department axis, used by the ticket CREATE form. $did = 0 returns every field.
// Rows carry the localized name, description, type, properties and options, plus
// department_name. Ordered by did DESC, then rank ASC.
public static function custom_fields($lang = '', $did = 0, $status = '');

// Access-group axis, used by the REPLY composer's credential rows. Reads the group's
// own comma separated list of field ids and returns them IN THAT ORDER, which is the
// drag-and-drop order the operator set. An empty group returns [].
public static function custom_fields_by_group($lang = '', $groupId = 0, $status = 'active');

// The groups themselves, for building the selector.
public static function access_groups($lang = '', $status = 'active');
public static function get_access_group($id = 0, $lang = '', $select = '');
Tickets::custom_fields() Department axis. An empty $status returns inactive definitions too.
Tickets::custom_fields_by_group() Access-group axis; values are encrypted under their own key namespace and shown masked.
filter:ticket.custom_fields Filters the department-axis list (language, department, status). By reference, return not used.
filter:ticket.access_group_fields The same for the access-group list; the return is not used.
Ticket field types text, textarea, number, password, select, radio, checkbox. A password skips the sanitiser.

Over the API

EndpointWhat it doesNote
GET /settings/client-fieldsLists definitionsLanguage is a parameter
POST /settings/client-fieldsCreates one definitionTwo languages, two calls
PATCH /settings/client-fields/{id}Updates one definitionNot partial for flags: an omitted flag becomes 0
PUT /settings/client-fields/orderReorders the listRank drives the display order
GET /tickets/custom-fieldsLists ticket definitionsSeparate engine and scope

Example

A field whose value must never sit in the database in the clear.

coremio/hooks/acme-secure-field.php
// The definition ids of the same logical field in each installed language. A client
// field is one row per language, so a single label maps to several ids.
const ACME_TAX_FIELD_IDS = [11, 12];

Hook::add('filter:custom_field.save_value', 10, function (&$value, $field_id, $owner_id) {
    if (!in_array($field_id, ACME_TAX_FIELD_IDS, true)) return;

    $value = trim((string) $value);
    if ($value === '') return;                      // an empty answer stays empty

    $value = Crypt::encode($value, Config::get('crypt/system') . '_ACME_TAX');
});

Hook::add('filter:custom_field.load_value', 10, function (&$value, $field_id, $owner_id) {
    if (!in_array($field_id, ACME_TAX_FIELD_IDS, true)) return;
    if ((string) $value === '') return;

    // decode() returns false on a value that was written before this listener existed,
    // so the original text is kept rather than blanking the member's answer.
    $plain = Crypt::decode((string) $value, Config::get('crypt/system') . '_ACME_TAX');
    if ($plain !== false) $value = $plain;
});

The reading side of the same contract; nothing downstream knows the value was encrypted.

reading the value from your own code
$uid = 42;

// Ask for the names you want. Every name comes back, missing rows as null.
$info = User::getInfo($uid, ['field_11', 'gsm']);
$tax  = (string) ($info['field_11'] ?? '');

// A multi-choice field is one comma joined string; split it back yourself.
$picked = array_values(array_filter(explode(',', (string) ($info['field_12'] ?? ''))));

// Write. The same names, the same "field_{id}" convention.
User::setInfo($uid, ['field_11' => 'GB123456789']);

// The reader memoises per request, so re-read with the third argument after a write.
$fresh = User::getInfo($uid, ['field_11'], true);

// Removing a row is not the same as writing an empty string: only the removal makes
// the account look like one that never answered.
if ($tax === '') User::deleteInfo($uid, ['field_11']);

The operator side, when your module ships its own field: one definition per language at install, and keep the ids.

creating a definition from a module
$ids = [];

foreach (['en' => 'VAT Number', 'tr' => 'Vergi Numarası'] as $lang => $label) {
    $ids[$lang] = WDB::insert('users_custom_fields', [
        'lang'          => $lang,
        'type'          => 'text',       // text | textarea | select | checkbox | radio
        'name'          => $label,
        'status'        => 'active',     // STRING, unlike every flag below
        'required'      => 0,
        'uneditable'    => 0,
        'client_hidden' => 0,
        'invoice'       => 1,
        'signForm'      => 1,
        'options'       => '',           // comma separated, for select/checkbox/radio
        'rank'          => 90,
    ]) ? WDB::lastID() : 0;
}

// Keep the ids: they are the only stable handle to the field, and they differ per language.
Config::setd('acme_vat_field_ids', Utility::jencode($ids));

Pitfalls

A field is one row per language, and the ids differ

No shared parent id joins the language variants, so hiding one leaves the other visible. Collect the ids as a set.

Hidden plus required would lock the member out

The gate redirects until every required field is answered, so required lookups exclude hidden fields.

The singular lookup is what refuses the write

A field left out of the list is a UI decision a forged post ignores; the singular reader is what refuses the write.

Adding a flag touches eight places, not one

A new flag needs the column, the write path, the form and its JavaScript branches, the list column, the locale keys, both API directions and every client surface.

The invoice reader returns no ids

It returns name and value pairs and drops empty values, so a lookup by id reports missing.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.