Adding Custom Fields
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.
| Table | Holds | Keyed by |
|---|---|---|
users_custom_fields | Language, type, label, options, flags, rank | id, one row per language |
users_informations | The value, as text | owner_id plus field_{id} |
tickets_custom_fields | The ticket field and its department | id; label and type in the _lang twin |
tickets_access_groups | The reply-side grouping and its field ids | id; 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
- Create the definition once per language, from Settings or the client-fields API.
- Choose the type:
text,textarea,select,checkbox,radio; choice types take anoptionslist. - Set the visibility flags; they are independent booleans and they compose.
- Note the id the row is given. Your code uses the id, not the label.
Read and Write the Value
- Write with the info helper, keyed by
field_{id}. - Read with the info reader: a name with no row comes back as
null, never as a missing key. - The reader memoises per request; pass the third argument to bypass it after a write.
- Delete rather than blank when "not answered" is meaningful: only a removed row triggers a fallback.
Transform the Value
- Listen on the save filter: once per
field_*key, before the write. - Listen on the load filter: on every read, after the rows are collected.
- Keep the two symmetric, or ciphertext ends up on the profile screen.
- 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.
active or inactive; an inactive field appears only in the operator's list.
The Value Helper
// 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;
phone and company_name mirror onto the member record.
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.
// 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) {
// ...
});
$value in place; the return is not used.
Where the Definitions Are Read
Every client surface applies its own filter, and they deliberately disagree.
| Surface | Reader | Filter it applies |
|---|---|---|
| Account profile | get_custom_fields(), account model | active, not hidden, member's language, by rank |
| Registration form | get_custom_fields(), sign model | the same, plus signForm = 1 |
| Invoice view | invoice_custom_fields() | invoice = 1; empty values dropped |
| Validation of one field | get_custom_field() | the list filter, on one id |
| Operator surfaces | get_custom_fields(), users model | language only; hidden included |
Ticket Fields and Their Two Axes
One definition table, two composers; the two memberships live in different places.
// 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 = '');
$status returns inactive definitions too.
password skips the sanitiser.
Over the API
| Endpoint | What it does | Note |
|---|---|---|
GET /settings/client-fields | Lists definitions | Language is a parameter |
POST /settings/client-fields | Creates one definition | Two languages, two calls |
PATCH /settings/client-fields/{id} | Updates one definition | Not partial for flags: an omitted flag becomes 0 |
PUT /settings/client-fields/order | Reorders the list | Rank drives the display order |
GET /tickets/custom-fields | Lists ticket definitions | Separate engine and scope |
Example
A field whose value must never sit in the database in the clear.
// 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.
$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.
$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
No shared parent id joins the language variants, so hiding one leaves the other visible. Collect the ids as a set.
The gate redirects until every required field is answered, so required lookups exclude hidden fields.
A field left out of the list is a UI decision a forged post ignores; the singular reader is what refuses the write.
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.
It returns name and value pairs and drops empty values, so a lookup by id reports missing.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.