Skip to content

Workflow Category Routing Developer Guide

Ed Mozley edited this page Nov 12, 2026 · 2 revisions

Workflow category routing - Developer Guide

How a workflow routes tickets by category (#298): the ticket.category_id condition on every ticket trigger, the ticket.category_changed trigger, subcategory matching and the setting behind it. For what it does for the person building a workflow, read Workflows - Routing tickets by category first; for the engine as a whole, Workflow engine - Developer Guide.

In plain words

Three small pieces, all in or next to WorkflowEngine:

  1. The field everywhere. ticket.category_id is in the full ticket field set, so the editor offers it on every ticket.* trigger. Rather than teach the dozen places that build ticket events to add it, dispatch() fills it in with one query when an event did not send it.
  2. A new trigger. TicketsService::updateTicket() fires ticket.category_changed when the category really changes - because email tickets arrive with no category and get one at triage, so ticket.created alone never sees it.
  3. Subcategories. When a condition on ticket.category_id uses in / not_in (what the editor's checkbox list saves), the ids are widened to include every category beneath them - unless the install setting workflow_category_match is exact.

No new table and no new column: the setting is a system_settings row.


1. πŸ“ The files involved

File What changed
workflow/includes/engine.php the trigger, the field, withTicketCategory(), categoryMatchIncludesSubcategories(), categoryIdsWithDescendants(), the condition loop
includes/services/tickets.php updateTicket() remembers a category change and dispatches ticket.category_changed; the update payload and eventTicket() carry category_id
includes/ticket_events.php ticketDispatchCreated() - the ticket.created payload carries category_id
tickets/settings/index.php, tickets/settings/manifest.php the Categories in workflows select on the Categories tab, saved on change; the key registered to that tab
workflow/editor.php, assets/js/workflow-editor.js window.WF_CATEGORY_TREE and the tip under a category condition (categoryMatchTip())
lang/*/tickets.php, lang/*/workflow.php tickets.settings.categories.wf_match_*, workflow.editor.category_tip_*
tests/workflow-category-routing.php 18 checks, real workflows fired by real tickets

2. The trigger and the field

availableTriggers() gains one entry:

            // GH #298. Email tickets arrive with no category and get one at triage,
            // so "route by category" needs this as well as ticket.created.
            'ticket.category_changed'  => 'A ticket\'s category changes',

availableFields() adds the field to $fullTicket, which every ticket.* trigger merges in, and gives the new trigger its two extras:

            'ticket.requester_email',
            // GH #298. Filled in by withTicketCategory() for every ticket event, so
            // "a ticket in Hardware" is a condition on any ticket trigger.
            'ticket.category_id',
        ];
...
            'ticket.category_changed' => array_merge($fullTicket, ['old_category_id', 'new_category_id']),

ticket.category_id was already in FIELD_LOOKUP_TABLES (for the Set category action, #209), and availableValuesForField() hands it to categoryLookupValues(), which labels each category with its full path - so the condition's checkbox list reads Hardware β†’ Laptop, not a bare Laptop. Since 5.1.0 old_category_id / new_category_id are in FIELD_LOOKUP_TABLES too (they were plain number boxes), and CATEGORY_FIELDS names all three: availableValuesForField() gives each the path-labelled list, evaluateConditions() applies the subcategory rule to each, and enrichWithLookupNames() adds {{old_category_name}} / {{new_category_name}} for free. The editor's tip (categoryMatchTip()) shows for all three.


3. πŸ”‘ Filling the field in once, in dispatch()

Ticket events are built in many places (replies, notes, SLA, assignment, the REST API...). Instead of adding category_id to each, dispatch() calls one helper just after the module-off check:

            if (self::moduleOffFor($conn, $event) !== null) return;
            $payload = self::withTicketCategory($conn, $event, $payload);   // GH #298
    private static function withTicketCategory(PDO $conn, string $event, array $payload): array
    {
        if (!str_starts_with($event, 'ticket.') || !is_array($payload['ticket'] ?? null)) return $payload;
        if (array_key_exists('category_id', $payload['ticket'])) return $payload;
        $id = (int)($payload['ticket']['id'] ?? 0);
        if ($id <= 0) return $payload;
        try {
            $s = $conn->prepare("SELECT category_id FROM tickets WHERE id = ?");
            $s->execute([$id]);
            $v = $s->fetchColumn();
            $payload['ticket']['category_id'] = ($v === false || $v === null) ? null : (int)$v;
        } catch (Throwable) { /* before Database Verify: no category, condition does not match */ }
        return $payload;
    }

array_key_exists, not isset. A payload that says category_id => null is believed - the ticket has no category - and costs no query. The two hottest paths now send it themselves, so they never query:

includes/ticket_events.php, ticketDispatchCreated():

                // GH #298: route a new ticket by its category.
                'category_id'         => $t['category_id']         !== null ? (int)$t['category_id']         : null,

includes/services/tickets.php, the update payload (re-read after the save) and eventTicket():

                'category_id'         => isset($r['category_id']) ? (int)$r['category_id'] : null,   // GH #298

Only ticket.* events with a ticket array and an id are touched; a task.* event carrying task.ticket_id (#274) is not, on purpose - it has no top-level ticket.


4. Firing ticket.category_changed

TicketsService::updateTicket() already compares each lookup field with its old value and skips an unchanged one. The category branch now remembers the change:

        $categoryChange = null;
        foreach ([
            'category_id'         => ['Category',         'category'],
...
            if ($newId === $oldId) {
                continue;
            }
            if ($field === 'category_id') $categoryChange = [$oldId, $newId];   // GH #298: ticket.category_changed

and, after the save, with the other change events:

            // GH #298. Email tickets get their category at triage, so routing by
            // category needs to hear about the change, not only about creation.
            if ($categoryChange !== null) {
                WorkflowEngine::dispatch('ticket.category_changed', [
                    'ticket' => $payload, 'old_category_id' => $categoryChange[0], 'new_category_id' => $categoryChange[1],
                ]);
            }

So it fires for set, change and clear (new_category_id null), from any caller of updateTicket() - the reading pane, the workspace, the REST API and the Set category workflow action - and not when the same value is saved again ($newId === $oldId continues first).

A type change that clears the category fires it too (5.1.0). The clear is made by the block just after this loop ("A category may be tied to one ticket type..."), which adds its own category_id = NULL update. Until 5.1.0 it did not set $categoryChange, so the event did not fire and routing on "left Hardware" missed it. It now sets $categoryChange = [(int) $catId, null] for category_id (not closure_category_id). An explicit category_id in the same request still wins - that block skips a field the caller set. Write-up.


5. Subcategory matching

The setting

workflow_category_match in system_settings: 'tree' or 'exact'. Never saved means tree - subcategories included - and so does any read error:

    public static function categoryMatchIncludesSubcategories(): bool
    {
        try {
            $s = connectToDatabase()->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'workflow_category_match'");
            $s->execute();
            return $s->fetchColumn() !== 'exact';
        } catch (Throwable) {
            return true;
        }
    }

It is install-wide (not per company) and registered to the Categories tab in tickets/settings/manifest.php, so the settings save endpoint accepts it from that tab:

                'ticket_resolution_code_enabled',
                'workflow_category_match',   // GH #298: does a workflow category condition match subcategories

Widening the ids

In the condition loop, only ticket.category_id with in / not_in:

            $tested = $value;
            $withSubs = false;
            if ($field === 'ticket.category_id' && in_array($op, ['in', 'not_in'], true) && self::categoryMatchIncludesSubcategories()) {
                $tested = self::categoryIdsWithDescendants($value);
                $withSubs = true;
            }
            $passed = self::evaluateOperator($op, $actual, $tested);
...
            if ($withSubs) $stepLog[array_key_last($stepLog)]['includes_subcategories'] = true;

not_in is widened too, so "category is not Hardware" also excludes Hardware β†’ Laptop - the mirror of in. equals / not_equals (only reachable by hand-written JSON or the REST API, since lookups offer is / is not) stay exact.

    private static function categoryIdsWithDescendants($value): array
    {
        $ids = is_array($value) ? $value : (is_string($value) ? explode(',', $value) : [$value]);
        $ids = array_values(array_unique(array_filter(array_map(fn($x) => (int)trim((string)$x), $ids))));
        if (!$ids) return [];
        try {
            $conn = connectToDatabase();
            $all = $ids;
            $frontier = $ids;
            for ($round = 0; $frontier && $round < 5; $round++) {
                $st = $conn->prepare('SELECT id FROM ticket_categories WHERE parent_id IN (' . implode(',', array_fill(0, count($frontier), '?')) . ')');
                $st->execute($frontier);
                $frontier = array_values(array_diff(array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN)), $all));
                $all = array_merge($all, $frontier);
            }
            return array_map('strval', $all);
        } catch (Throwable) {
            return array_map('strval', $ids);
        }
    }
  • Breadth-first by level, one query per level. Categories are at most three deep, so two rounds find everything; the cap of 5 and the array_diff against $all guard against a bad parent_id loop.
  • Retired categories are included. There is no is_active filter: a ticket still in a retired subcategory should still route with its parent.
  • Strings out, because the editor saves the ticked ids as strings and evaluateOperator() compares loosely against the payload's int.
  • On a database error it falls back to the ids as ticked - an exact match, never a broader one.

The setting is read once per category condition evaluated (one small query); nothing is cached across requests, so a change applies to the next event.


6. The settings control and the editor tip

tickets/settings/index.php, Categories tab, under the field switches:

            <h4 class="tc-sub4"><?php echo htmlspecialchars(t('tickets.settings.categories.wf_match_heading')); ?></h4>
            <div class="form-group" style="max-width: 520px;">
                <label for="tcWorkflowMatch"><?php echo htmlspecialchars(t('tickets.settings.categories.wf_match_label')); ?></label>
                <select id="tcWorkflowMatch">
                    <option value="tree"><?php echo htmlspecialchars(t('tickets.settings.categories.wf_match_tree')); ?></option>
                    <option value="exact"><?php echo htmlspecialchars(t('tickets.settings.categories.wf_match_exact')); ?></option>
                </select>

It loads from get_system_settings.php and saves on change through save_system_settings.php (anything but exact is written as tree), showing the Saved toast - no Save button involved, like the portal field choices.

workflow/editor.php tells the editor which way it is set:

        window.WF_CATEGORY_TREE  = <?php echo WorkflowEngine::categoryMatchIncludesSubcategories() ? 'true' : 'false'; ?>;

and assets/js/workflow-editor.js (?v=25) appends a tip under the checkbox list when the condition's field is ticket.category_id:

    function categoryMatchTip() {
        const t = k => window.t('workflow.editor.' + k);
        return '<div style="margin-top:8px; padding:7px 10px; border-radius:5px; font-size:12px; line-height:1.5; background:var(--info-bg, #eef6ff); color:var(--text, #333);">' +
            escAttr(t(window.WF_CATEGORY_TREE ? 'category_tip_tree' : 'category_tip_exact')) + ' ' +
            '<a href="../tickets/settings/#categories" target="_blank" rel="noopener">' + escAttr(t('category_tip_link')) + '</a></div>';
    }

The flag is read when the editor page loads; change the setting in another tab and the tip is right after a reload.


7. πŸ§ͺ Testing

FREEITSM_TEST_DATABASE=<the throwaway database config.php uses> php tests/workflow-category-routing.php

18 checks - see Developer Tests - Security & access. It makes ZZ categories (Hardware with a Laptop child), real active ZZ workflows with an Add a note action, and real tickets through TicketsService, then checks the notes:

  • the field is offered on every ticket trigger; the trigger exists with its two fields; the picker labels the subcategory with its path;
  • never saved = subcategories included; a Hardware workflow runs for a Laptop ticket, not for another category or none;
  • set to exact: the Laptop ticket no longer matches, Hardware still does; the run history carries "includes_subcategories":true when it applied;
  • setting a category fires the trigger and routes it, with old_category_id null and new_category_id the new one; saving the same category again fires nothing;
  • a note on a Laptop ticket - an event that never carried a category - matches, because the engine looked it up;
  • (5.1.0) old_category_id and new_category_id are lookups whose list includes the subcategory; a type change that clears a Laptop category fires the trigger with old_category_id Laptop and new_category_id null, and a workflow on old category is Hardware catches it. 24 checks; against 5.0.0 those five fail.

Other active workflows on the three triggers used are paused for the run and put back; every ZZ row is removed in finally and the setting restored.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally