Repository navigation
Task Workflows Developer Guide
How a workflow on a task trigger reaches the task's ticket, and the three actions that change a task. Built for #274 (5.0.0). User guide: Workflows β Task workflows and their ticket. The engine as a whole: Workflow engine - Developer Guide.
The one-line version: every task event now carries task.ticket_id. The engine adds the ticket's number, subject and link at run time. A ticket action added to a task workflow starts with {{task.ticket_id}} instead of {{ticket.id}} (an arg's trigger_defaults, applied by the editor). Three new actions (set_task_status, assign_task, add_task_comment) go through TasksService as the workflow, so they keep every rule the screen has. A comment by a workflow is written by System, which needed task_comments.analyst_id to allow NULL.
The issue's example, "when a task is marked Done, add a note to its ticket", used to need a webhook out to n8n and back into the REST API. It is now a template: When a task is done, note it on its ticket.
| File | π¨ | What changed |
|---|---|---|
includes/services/tasks.php |
βοΈ | every task dispatch carries ticket_id; taskTicketId(); createComment() stores NULL for "no person" |
workflow/includes/engine.php |
βοΈ |
task.ticket_id on every task trigger; enrichWithTaskTicket(); the three merge codes; trigger_defaults; the no-ticket message; set_task_status, assign_task, add_task_comment; taskActionContext(), taskForAction(); ACTION_MODULES
|
workflow/includes/templates.php |
βοΈ | the recipe task_done_note_on_ticket
|
assets/js/workflow-editor.js, workflow/editor.php (v24) |
π₯οΈ |
argDefault(); the starting value follows a trigger change |
database/freeitsm.sql, includes/db_verify_schema.php, api/system/db_verify.php
|
ποΈ |
task_comments.analyst_id NULL, and the probe that relaxes it on existing installs |
api/tasks/get.php, assets/js/tasks.js (v76) |
π₯οΈ | a System comment is shown as System |
includes/case_report.php, includes/case_report_page.php
|
π | a System comment or note says FreeITSM, not "former analyst" |
workflow/help.php, lang/*/workflow.php, lang/*/tasks.php
|
π₯οΈ | help rows 15-17, the callout, the recipe mention; tasks.detail.comment_by_system
|
includes/feature_bingo/cards/workflow.php |
π | workflow.task_ticket |
tests/workflow-task-actions.php |
π§ͺ | section 9 |
tasks.ticket_id already existed ("the ticket this task belongs to"), but no task event sent it, so {{task.ticket_id}} rendered empty. Every dispatch in TasksService now includes it. The shared helper for change events reads it with the rest of the row:
// includes/services/tasks.php
private static function taskEventDispatch(PDO $conn, string $event, int $taskId, array $extra = []): void
{
try {
$rb = $conn->prepare("SELECT title, status_id, priority_id, assigned_analyst_id, due_date, ticket_id FROM tasks WHERE id = ?");
$rb->execute([$taskId]);
$taskRow = $rb->fetch(PDO::FETCH_ASSOC) ?: [];
WorkflowEngine::dispatch($event, [
'task' => array_merge([
'id' => $taskId,
'title' => $taskRow['title'] ?? null,
'status_id' => isset($taskRow['status_id']) ? (int)$taskRow['status_id'] : null,
'priority_id' => isset($taskRow['priority_id']) ? (int)$taskRow['priority_id'] : null,
'assignee_id' => isset($taskRow['assigned_analyst_id']) ? (int)$taskRow['assigned_analyst_id'] : null,
'due_date' => $taskRow['due_date'] ?? null,
'ticket_id' => !empty($taskRow['ticket_id']) ? (int)$taskRow['ticket_id'] : null, // GH #274
], $extra),
]);
} catch (Exception $wfEx) {
error_log('Workflow dispatch error in task service (' . $event . '): ' . $wfEx->getMessage());
}
}| Event | Where its ticket_id comes from |
|---|---|
task.status_changed, task.due_date_changed, task.comment_added
|
taskEventDispatch() above |
task.created |
taskTicketId(), because the payload is built from the input rather than read back |
task.assigned |
assignedDispatch()'s own read-back gains ticket_id
|
task.completed |
completedDispatch()'s read-back gains ticket_id
|
task.collaborator_added / _removed
|
collaboratorDispatch()'s read-back gains ticket_id
|
task.deleted |
$row['ticket_id'] from the row loaded before the delete |
/**
* The ticket a task belongs to (tasks.ticket_id), or null - for task.created, whose payload
* is built from the input rather than read back (GH #274).
*
* TRAP: it travels as task.ticket_id, never as a top-level `ticket` object. A `ticket` key
* would make the event look like a ticket event: workflowEventTenantId() reads `ticket` first
* (the task and its ticket can be in different companies), and the notification router and
* search indexer both act on payload['ticket'].
*/
private static function taskTicketId(PDO $conn, int $taskId): ?int
{
$s = $conn->prepare("SELECT ticket_id FROM tasks WHERE id = ?");
$s->execute([$taskId]);
$v = $s->fetchColumn();
return $v ? (int)$v : null;
}The tempting design was to add ticket: {id, number, β¦} to task payloads, so every ticket action's default {{ticket.id}} would just work. Three things read $payload['ticket'] and would have misbehaved:
// includes/workflow_tenancy.php - workflowEventRecordTables(): the FIRST record found decides the event's company
'ticket' => ['tickets', 'default'],
'task' => ['tasks', 'default'],-
The company would be wrong. With a
ticketkey, a task event would take its ticket's company, and a company's own workflows would run (or not) for the wrong company. -
The notification router (
includes/notifications_router.php) reads$payload['ticket']['id']and['assigned_analyst_id']to decide whom to tell. -
The search indexer (
includes/search/indexer.php) reindexes$payload['ticket']['id']on every event.
So the ticket rides under task, and the editor changes the starting value instead (section 4).
// workflow/includes/engine.php - availableFields(), after $byTrigger is built
// GH #274: every task event carries the ticket the task belongs to (TasksService),
// so a task workflow can act on its ticket - or check it has one.
foreach ($byTrigger as $k => $f) {
if (str_starts_with($k, 'task.') && !in_array('task.ticket_id', $f, true)) $byTrigger[$k][] = 'task.ticket_id';
}private const FIELD_TYPES = [
// β¦
'task.id' => 'numeric',
'task.ticket_id' => 'numeric', // GH #274The editor shows it as Task Β· Ticket (humaniseFieldPath() drops the _id). The recipe uses is_not_empty on it, so tasks without a ticket are skipped.
public static function enrichPayloadForTemplates(PDO $conn, array $payload): array
{
$payload = self::enrichWithLookupNames($conn, $payload);
$payload = self::enrichWithReferences($conn, $payload);
$payload = self::enrichWithTaskTicket($conn, $payload); // GH #274
return self::enrichWithLinks($conn, $payload);
}
/**
* GH #274: {{task.ticket_number}}, {{task.ticket_subject}} and {{task.ticket_url}} beside
* {{task.ticket_id}}. Kept under `task` on purpose - see TasksService::taskTicketId().
* Never clobbers; a missing ticket (deleted, or none) simply adds nothing.
*/
public static function enrichWithTaskTicket(PDO $conn, array $payload): array
{
$id = self::dotGet($payload, 'task.ticket_id');
if ($id === null || $id === '' || !is_numeric($id) || (int)$id <= 0) return $payload;
try {
$st = $conn->prepare('SELECT ticket_number, subject FROM tickets WHERE id = ?');
$st->execute([(int)$id]);
$t = $st->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) { $t = false; }
if (!$t) return $payload;
if (self::dotGet($payload, 'task.ticket_number') === null) self::dotSet($payload, 'task.ticket_number', (string)$t['ticket_number']);
if (self::dotGet($payload, 'task.ticket_subject') === null) self::dotSet($payload, 'task.ticket_subject', (string)$t['subject']);
if (self::dotGet($payload, 'task.ticket_url') === null) {
try {
require_once dirname(__DIR__, 2) . '/includes/public_url.php';
self::dotSet($payload, 'task.ticket_url', rtrim(publicBaseUrl($conn), '/') . '/' . self::RECORD_LINKS['ticket'] . (int)$id);
} catch (Throwable) { /* a missing link must never break a run */ }
}
return $payload;
}enrichPayloadForTemplates() is the one entry point for both a real run and the editor's Send-test preview, so a preview can't drift from production. The codes are offered in the variable picker only for triggers that carry task.ticket_id:
// availableVariables()
if (in_array('task.ticket_id', $fields, true)) {
$vars[] = ['path' => 'task.ticket_number', 'label' => 'Task Β· Ticket number', 'note' => 'The reference of the ticket this task belongs to, e.g. ETC-623-64409. Empty when the task has no ticket.'];
$vars[] = ['path' => 'task.ticket_subject', 'label' => 'Task Β· Ticket subject', 'note' => 'The subject of the ticket this task belongs to.'];
$vars[] = ['path' => 'task.ticket_url', 'label' => 'Task Β· Ticket link', 'note' => 'The full address of the task\'s ticket in FreeITSM.'];
}Every ticket action's ticket_id arg defaults to {{ticket.id}}, which is empty on a task trigger. Rather than make the author type {{task.ticket_id}}, an arg can say what it starts as on a given trigger family.
// availableActions() - the action list is built into $actions, then:
// GH #274: on a task trigger, a ticket-id arg starts as the TASK's ticket, so
// "task done -> note on its ticket" needs nothing typed. The editor applies it to
// new actions and swaps it when the trigger changes (workflow-editor.js).
foreach ($actions as &$a) {
foreach ($a['args'] as &$arg) {
if (is_array($arg) && ($arg['default'] ?? null) === '{{ticket.id}}') $arg['trigger_defaults'] = ['task.' => '{{task.ticket_id}}'];
}
unset($arg);
}
unset($a);
return $actions;That catches every action with a {{ticket.id}} default (set_ticket_status, add_ticket_note, send_email, create_task, and so on), including ones added later, with no per-action change. The unset() after each by-reference foreach is required: without it, the next loop over the same variable writes through the dangling reference.
// assets/js/workflow-editor.js
/**
* An arg's starting value. trigger_defaults (GH #274) win when the workflow's trigger
* starts with one of their prefixes - a ticket action on a task trigger starts as
* {{task.ticket_id}}; then new_default, for a NEWLY added action only (GH #268); then default.
*/
function argDefault(s, isNew) {
if (!s || typeof s !== 'object') return undefined;
if (s.trigger_defaults && triggerEvent) {
for (const pre of Object.keys(s.trigger_defaults)) if (triggerEvent.startsWith(pre)) return s.trigger_defaults[pre];
}
if (isNew && s.new_default != null) return s.new_default;
return s.default != null ? s.default : undefined;
}It is used in four places: adding an action (addAction(), isNew true), changing an action's type (updateActionTypeFromDetail(), true), and drawing the arg form, both the shown value and the value seeded into n.args so it round-trips through save (false):
const shownDefault = argDefault(norm, false);
const currentVal = (argName in n.args) ? n.args[argName] : (shownDefault !== undefined ? shownDefault : '');
// β¦
if (!(argName in n.args) && shownDefault !== undefined) n.args[argName] = shownDefault;function updateTriggerFromDetail() {
triggerEvent = document.getElementById('wfTrigger').value;
// GH #274: an arg still holding a starting value follows the new trigger -
// {{ticket.id}} becomes {{task.ticket_id}} on a task trigger, and back. A value
// somebody typed is left alone.
nodes.forEach(n => {
if (n.kind !== 'action' || !n.args) return;
const def = (window.WF_ACTION_DEFS || {})[n.type] || {};
Object.keys(def.args || {}).forEach(k => {
const s = def.args[k];
if (!s || typeof s !== 'object' || !s.trigger_defaults) return;
const starts = [s.default].concat(Object.values(s.trigger_defaults));
if (starts.includes(n.args[k])) n.args[k] = argDefault(s, false);
});
});
// β¦ redraw the open action's form and the nodes
}Saved workflows are never rewritten. A value changes only in the editor, only when it still equals one of the starting values, and only when the person saves.
Without help, {{task.ticket_id}} renders empty and the action fails with ticket_id is required, which reads like a broken workflow. executeAction() says it plainly instead:
private static function executeAction(string $type, array $args, array $payload)
{
// GH #274: a ticket action on a task workflow whose task has no ticket. Said plainly,
// rather than "ticket_id is required", which reads like a broken workflow.
if (isset($args['ticket_id']) && is_string($args['ticket_id']) && str_contains($args['ticket_id'], 'task.ticket_id')
&& trim(self::renderTemplate($args['ticket_id'], $payload)) === '' && $type !== 'create_task') {
throw new Exception('This task is not linked to a ticket, so there is no ticket to act on. Add the condition "Task Β· Ticket" is not empty to run only for tasks that have one.');
}
switch ($type) {
case 'set_task_status': return self::action_set_task_status($args, $payload); // GH #274
case 'assign_task': return self::action_assign_task($args, $payload);
case 'add_task_comment': return self::action_add_task_comment($args, $payload);
// β¦ every other action
}
}create_task is excluded: there ticket_id is optional ("link the new task to this ticket, if any").
Company scope is unchanged. Every ticket action already calls assertRecordInRunCompany($conn, 'tickets', $ticketId, 'ticket'), so a company's own workflow can't reach another company's ticket through a task. The check is on the ticket the action names, however it got there.
$taskIdArg = [ // GH #274
'type' => 'text',
'label' => 'Task ID',
'default' => '{{task.id}}',
'supports_vars' => true,
'required' => true,
];
// β¦
'set_task_status' => [
'label' => 'Set task status',
'description' => 'Change a task\'s status. Moving it to a closed status completes it, exactly as on screen: β¦',
'args' => [
'task_id' => $taskIdArg,
'status_id' => ['type' => 'lookup', 'label' => 'New status', 'lookup' => 'task_status', 'required' => true],
],
],
'assign_task' => [
'label' => 'Assign task to analyst', 'description' => 'β¦',
'args' => ['task_id' => $taskIdArg, 'analyst_id' => ['type' => 'lookup', 'label' => 'Assign to', 'lookup' => 'analyst', 'required' => true]],
],
'add_task_comment' => [
'label' => 'Add a comment to the task', 'description' => 'β¦',
'args' => ['task_id' => $taskIdArg, 'comment' => ['type' => 'textarea', 'label' => 'Comment', 'required' => true, 'supports_vars' => true]],
],const ACTION_MODULES = [
'create_task' => 'tasks',
'set_task_status' => 'tasks', // GH #274
'assign_task' => 'tasks',
'add_task_comment' => 'tasks',
// β¦
];ACTION_MODULES hides an action from a new workflow while its module is switched off (GH #217). The task_status lookup reuses FIELD_LOOKUP_TABLES['task.status_id'] through ACTION_ARG_LOOKUPS, so the status dropdown is the same list the conditions use.
/**
* Who a task action acts as: nobody - the workflow - recorded in the task's history
* as "workflow". No company scope of its own: assertRecordInRunCompany() is the
* boundary, as for tickets.
*/
private static function taskActionContext(): ActorContext
{
require_once dirname(__DIR__, 2) . '/includes/service_context.php';
require_once dirname(__DIR__, 2) . '/includes/services/tasks.php';
$name = 'Workflow';
if (self::$ctxWorkflowId) {
try {
$st = connectToDatabase()->prepare('SELECT name FROM workflows WHERE id = ?');
$st->execute([(int)self::$ctxWorkflowId]);
$n = $st->fetchColumn();
if ($n) $name = 'Workflow: ' . $n;
} catch (Throwable) { /* the plain name will do */ }
}
return new ActorContext(0, null, 'workflow', 'en', mb_substr($name, 0, 100));
}
/** The task the action names, checked to exist and to be in the run's company. */
private static function taskForAction(PDO $conn, array $args, array $payload): int
{
$taskId = self::argInt($args, 'task_id', $payload);
if (!$taskId) throw new Exception('task_id is required');
$st = $conn->prepare('SELECT 1 FROM tasks WHERE id = ?');
$st->execute([$taskId]);
if (!$st->fetchColumn()) throw new Exception("Unknown task_id: {$taskId}");
self::assertRecordInRunCompany($conn, 'tasks', $taskId, 'task');
return $taskId;
}ActorContext(0, null, 'workflow', β¦):
-
Actor 0 passes
TasksService::assertCanWrite(), which only refuses a person with read-only Tasks access. -
companyScopenull means the service applies no company scope of its own.taskForAction()has already applied the workflow's company. -
'workflow'with a name makesTaskHistory::actor()record[null, 'workflow', 'Workflow: <name>']:
// includes/services/task_history.php
public static function actor(?ActorContext $ctx): array
{
if ($ctx === null) return [null, 'system', null];
if ($ctx->actorId > 0) {
return [$ctx->actorId, $ctx->source === 'api' ? 'api' : 'analyst', $ctx->actorName !== '' ? $ctx->actorName : null];
}
$kind = in_array($ctx->source, ['portal', 'workflow', 'api'], true) ? $ctx->source : 'system';
return [null, $kind, $ctx->actorName !== '' ? $ctx->actorName : null];
}private static function action_set_task_status(array $args, array $payload): array
{
$conn = connectToDatabase();
$taskId = self::taskForAction($conn, $args, $payload);
$statusId = self::argInt($args, 'status_id', $payload);
if (!$statusId) throw new Exception('status_id is required');
$st = $conn->prepare('SELECT name FROM task_statuses WHERE id = ?');
$st->execute([$statusId]);
$name = $st->fetchColumn();
if ($name === false) throw new Exception("Unknown status_id: {$statusId}");
try {
TasksService::saveTask($conn, self::taskActionContext(), ['id' => $taskId, 'status_id' => $statusId]);
} catch (ServiceError $e) {
throw new Exception($e->getMessage()); // the completion gate's refusal becomes the step's failure
}
return ['task_id' => $taskId, 'status_id' => $statusId, 'status_name' => (string)$name];
}
private static function action_assign_task(array $args, array $payload): array
{
$conn = connectToDatabase();
$taskId = self::taskForAction($conn, $args, $payload);
$analystId = self::argInt($args, 'analyst_id', $payload);
if (!$analystId) throw new Exception('analyst_id is required');
self::assertAnalystReachesRunCompany($conn, $analystId);
$st = $conn->prepare('SELECT full_name FROM analysts WHERE id = ? AND COALESCE(is_active, 1) = 1');
$st->execute([$analystId]);
$name = $st->fetchColumn();
if ($name === false) throw new Exception("Unknown or inactive analyst_id: {$analystId}");
try {
TasksService::saveTask($conn, self::taskActionContext(), ['id' => $taskId, 'assigned_analyst_id' => $analystId]);
} catch (ServiceError $e) {
throw new Exception($e->getMessage());
}
return ['task_id' => $taskId, 'analyst_id' => $analystId, 'analyst_name' => (string)$name];
}
private static function action_add_task_comment(array $args, array $payload): array
{
$conn = connectToDatabase();
$taskId = self::taskForAction($conn, $args, $payload);
$text = self::argString($args, 'comment', $payload);
if (trim($text) === '') throw new Exception('comment is required');
try {
$id = TasksService::createComment($conn, self::taskActionContext(), $taskId, $text);
} catch (ServiceError $e) {
throw new Exception($e->getMessage());
} catch (PDOException $e) {
throw new Exception('Run System -> Database Verify so a workflow can write a comment with no author: ' . $e->getMessage());
}
return ['task_id' => $taskId, 'comment_id' => $id, 'comment_length' => mb_strlen($text)];
}Why through TasksService, not an UPDATE. saveTask() is what the screen and the REST API use, so a workflow inherits all of it:
- the completion gate (
ChecklistsService::assertTaskCompletionAllowed()): required checklist steps and required task forms set to block refuse, and the step fails naming them; -
completed_datetime; - the history;
- the events
task.status_changed/task.completed/task.assigned, so the assignee is told, other workflows run, and the calendar follows.
set_ticket_status, by contrast, writes tickets directly and has to call the closure gates by hand. That's exactly the kind of drift this avoids.
What each step returns is chained like every action's: {{last.status_name}}, or {{steps.2.comment_id}}.
createComment() inserted $ctx->actorId, and for a workflow that is 0, which fk_task_comments_analyst refuses. NULL now means System, as it already did for ticket notes (#281):
// includes/services/tasks.php - createComment()
// No person (a workflow - GH #274) is NULL: System. Never 0, which the foreign key refuses.
$conn->prepare("INSERT INTO task_comments (task_id, analyst_id, comment, created_datetime) VALUES (?, ?, ?, UTC_TIMESTAMP())")
->execute([$taskId, $ctx->actorId > 0 ? $ctx->actorId : null, $text]);-- database/freeitsm.sql
CREATE TABLE IF NOT EXISTS `task_comments` (
`id` INT NOT NULL AUTO_INCREMENT,
`task_id` INT NOT NULL,
`analyst_id` INT NULL, -- NULL = System: a workflow wrote it (GH #274)
β¦
CONSTRAINT `fk_task_comments_analyst` FOREIGN KEY (`analyst_id`) REFERENCES `analysts` (`id`)Database Verify's column pass adds missing columns, but doesn't loosen existing ones, so api/system/db_verify.php has a probe beside the #281 one:
// `task_comments.analyst_id` was NOT NULL, so a comment no person wrote - the
// workflow action "Add a comment to the task" (GitHub #274) - had no honest value,
// and 0 is refused by `fk_task_comments_analyst`. NULL means System, as it does on
// ticket notes (#281). Relaxing it invalidates no existing row; safe to re-run.
try {
$tcCol = $conn->prepare(
"SELECT IS_NULLABLE FROM information_schema.columns
WHERE table_schema = ? AND table_name = 'task_comments' AND column_name = 'analyst_id'"
);
$tcCol->execute([$dbName]);
$tcRow = $tcCol->fetch(PDO::FETCH_ASSOC);
if ($tcRow && strtoupper($tcRow['IS_NULLABLE']) === 'NO') {
$conn->exec("ALTER TABLE `task_comments` MODIFY `analyst_id` INT NULL");
$results[] = ['table' => 'task_comments', 'status' => 'updated',
'details' => ["analyst_id: NOT NULL β NULL (a comment a workflow writes is System)"]];
}
} catch (Exception $e) {
// Non-fatal: comments keep working for people; only a workflow's comment is refused.
}Until Database Verify runs, the action fails with a message saying so (the PDOException catch above), and comments by people keep working.
| Reader | Change |
|---|---|
api/tasks/get.php (the task panel) |
JOIN β LEFT JOIN, or a System comment vanishes from the panel; returns by_system
|
assets/js/tasks.js |
shows tasks.detail.comment_by_system (System) when there is no name |
includes/case_report.php / case_report_page.php
|
by_system β FreeITSM (case-report.by_system), instead of "former analyst", which is for a deleted analyst. The same fix applies to ticket notes by System |
api/v1/resources/tasks.php, task_history.php, evidence_export.php
|
were already LEFT JOIN; the analyst is null |
-- api/tasks/get.php
SELECT c.id, c.comment, c.created_datetime, a.full_name AS analyst_name, (c.analyst_id IS NULL) AS by_system
FROM task_comments c
LEFT JOIN analysts a ON c.analyst_id = a.id -- LEFT: a System comment has no analyst (GH #274)
WHERE c.task_id = ?
ORDER BY c.created_datetime ASC// assets/js/tasks.js - the comment list
<span class="comment-author">${esc(c.analyst_name || (Number(c.by_system) ? window.t('tasks.detail.comment_by_system') : ''))}</span>// workflow/includes/templates.php
'task_done_note_on_ticket' => [
'name' => 'When a task is done, note it on its ticket',
'category' => 'Tasks',
'description' => 'When a task linked to a ticket is completed, writes an internal note on that ticket naming the task and who owned it - β¦',
'trigger_event' => 'task.completed',
'conditions' => [
['field' => 'task.ticket_id', 'op' => 'is_not_empty', 'value' => ''],
],
'actions' => [
[
'type' => 'add_ticket_note',
'args' => [
'ticket_id' => '{{task.ticket_id}}',
'note' => "Task completed: {{task.title}} (owner: {{task.assignee_name}}).",
'write_to' => 'note', // a real note by System (#268), not the history
'shared' => false,
],
],
],
],{{task.assignee_name}} comes from enrichWithLookupNames(), which gives every lookup id a _name twin. As with every recipe, a cloned workflow starts inactive.
A workflow on task.status_changed that sets a task status, or on task.comment_added that adds a comment, would trigger itself. Section 6 deliberately goes through the service, which dispatches those events. The engine's existing guards stop the loop:
// workflow/includes/engine.php
// 1. $activeWorkflowIds β the stack of workflow ids currently running.
// A workflow already on the stack is refused re-entry. β¦
// 2. MAX_CHAIN_DEPTH β a hard ceiling on nesting depth (10).
// 3. MAX_RUNS_PER_REQUEST β a ceiling on total runs in one request (100).So the second run of the same workflow is refused, and recorded as an aborted execution so the author can see why. A task β ticket cycle (task done β note on ticket β a ticket workflow that changes the task β β¦) is stopped the same way: it must revisit a workflow still on the stack.
tests/workflow-task-actions.php runs on the throwaway database config.php uses, with Database Verify first. 23 checks. It runs the issue's example end to end with a real, active workflow, then each action through WorkflowEngine::runActionList():
// The example: a real workflow, fired by completing a ZZ task through TasksService
$conn->prepare("INSERT INTO workflows (name, trigger_event, conditions, actions, is_active) VALUES (?, 'task.completed', ?, ?, 1)")->execute([
"$tag sample",
json_encode($tpl['conditions']),
json_encode([['type' => 'add_ticket_note', 'args' => ['ticket_id' => '{{task.ticket_id}}',
'note' => "$tag done: {{task.title}} on {{task.ticket_number}}", 'write_to' => 'note']]]),
]);
TasksService::saveTask($conn, $ctx, ['id' => $linked, 'status_id' => $closed]);
// β a ticket note "ZZ274-β¦ done: ZZ274-β¦ linked on <ticket number>", internal, analyst_id NULL
TasksService::saveTask($conn, $ctx, ['id' => $unlinked, 'status_id' => $closed]);
// β no note; the execution is recorded as 'skipped'| Area | Proves |
|---|---|
| Designer |
task.ticket_id on task triggers; the three merge codes offered; ticket actions carry trigger_defaults for task., and keep {{ticket.id}} as their default; the three actions exist; the recipe's trigger and action are real; enrichWithTaskTicket() fills number, subject and link |
| The example | the linked task's completion writes the note with the title and ticket number, internal, by System; the unlinked task is skipped by the condition and recorded as skipped |
| No ticket | a ticket action on a task with no ticket fails with not linked to a ticket |
set_task_status |
the task moves; the history says workflow and names it; a required task form set to block stops completion, and the step names the form |
assign_task |
the owner changes; an unknown analyst fails the step |
add_task_comment |
the comment is written with merge codes, analyst_id NULL; Database Verify made the column nullable; an unknown task fails |
FREEITSM_TEST_DATABASE=<the throwaway db config.php uses> php tests/workflow-task-actions.php
Also run after it: workflow-ticket-note, workflow-company-scope, workflow-set-ticket-field, task-forms, task-drag-notifies, case-report, analyst-lifecycle.
Browser check (headless Chrome on a test install): in the editor, on trigger task.completed, a new Add a note to the ticket action shows {{task.ticket_id}} and a new Set task status shows {{task.id}}. On ticket.created, a new ticket action shows {{ticket.id}}.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- π PDF exports moved to PDFKit (#254)
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- β³ π Switching modules off
- β³ π οΈ Module availability β Developer Guide
- β³ π Module availability β design (#217)
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
-
π¦ Microsoft Entra ID
- β³ π₯ Importing people and groups
- β³ π» Intune: assets to their primary user
- β³ π’ Organisation chart
- β³ π οΈ Entra directory sync β Developer Guide
- β³ π οΈ Entra import - review (#235)
- β³ π οΈ Entra follow-ups - review (#237-#240)
- β³ π οΈ Intune primary user - review (#236)
- β³ π οΈ Organisation chart - reviews (#242, #265)
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π οΈ Empty Procedures fold - review (#247)
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π REST API: Chart of accounts
- β³ π REST API: Projects
- β³ π‘οΈ API threat protection
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Reply subject and replying to one message
- β³ π Live updates
- β³ π οΈ Watching a ticket - Developer Guide (1 of 2)
- β³ βοΈ Ticket address suggestions - Developer Guide
- β³ π οΈ CSAT delay and reminder - Developer Guide
- β³ π οΈ Ticket checklist step types - Developer Guide (1 of 2)
- β³ π οΈ Right-click Attach checklist - Developer Guide
- β³ π οΈ Checklists in the REST API - Developer Guide
- β³ π A ticket's related block and attachments - Developer Guide
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Floating windows & the file viewer β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Simpler ticket and task forms - design (#218)
- β³ ποΈ The ticket workspace
- β³ π Forms on tickets
- β³ π The ticket table
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π§Ύ A type for every new ticket
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ π Ticket dashboard β Developer Guide
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
-
β³ Basic IMAP mailboxes
- β³ π Basic IMAP without the PHP extension (#257)
- β³ π οΈ Basic IMAP client β Developer Guide
- β³ π§ͺ Basic IMAP client β Testing and verifying
- β³ π οΈ Basic IMAP - Sent copy - Developer Guide (#270)
- β³ π οΈ Verifying a mail folder β Developer Guide
- β³ π οΈ Mailbox providers β Developer Guide
- β³ π οΈ How Gmail mail collection works β Developer Guide
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
-
Tasks
- β³ π οΈ Task board and subtasks β Developer Guide
- β³ π§βπ€βπ§ People on a task ("Involved")
- β³ π Repeating tasks
- β³ π’ Moving a task between companies
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Linked configuration item
- β³ π Connected assets
- β³ π Asset review
-
β³ π·οΈ QR asset labels
- β³ π οΈ QR asset labels β Developer Guide
- β³ π Asset label designer - design (#229)
- β³ π· Asset scanning β Developer Guide
- β³ π οΈ Asset tag numbering β Developer Guide
- β³ π οΈ Asset reconciliation β Developer Guide
- β³ π οΈ Reconciliation, tags + labels - review (#164)
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ πͺ Hyper-V and Failover Clusters
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Starters & leavers
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
-
CMDB
- β³ π οΈ CMDB β Developer Guide
- β³ π οΈ Impact analysis β Developer Guide
- β³ π οΈ Data quality β Developer Guide
- β³ π Linked assets
- β³ π Linking an asset to its CI - design (#201)
- β³ π Discovery and redundancy-aware impact - design (#199)
- β³ π οΈ Relationship wording - review (#297)
- Network Mapper
-
Workflows
- β³ π οΈ Workflow engine β Developer Guide
- β³ π οΈ Workflows for tasks β Developer Guide
- β³ π οΈ Workflow notes on a ticket β Developer Guide
- β³ π οΈ Workflow category routing β Developer Guide
- β³ β° Time-Based Triggers
- β³ πͺ Webhooks
- β³ π¬ Webhook Message Formats
- β³ π HTTPS Certificates & CA Bundles
- β³ π οΈ SSL Verification β the Global Setting
- β³ π Workflow & Webhook Pitfalls
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
-
System
- β³ π οΈ Preferences β Developer Guide
- β³ π§Ή Maintenance
- β³ π Finding people in the Analysts list
- β³ πͺ When an analyst leaves
- β³ π οΈ System β Branding β Developer Guide
- β³ π©Ί Page health (D019) β Developer Guide
- β³ β Feature Bingo
- β³ π·οΈ Cost centres
- β³ π Chart of accounts
- β³ π· Photo Album
- β³ Demo Data
- β³ π οΈ Demo Data β Developer Guide
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ One company supporting another
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πͺ #326/#327 Form webhook Full record
- β³ π #325 Form PDF download failed
- β³ π Small fixes in 5.1.0
- β³ π CMDB relationships read backwards (5.1.0)
- β³ π #301 CSAT delay was never applied
- β³ π #281 Comments imported from Jira never arrived
- β³ π #288 A change failed to open with Enforce approvals on
- β³ π Long non-Latin articles never got an embedding
- β³ π« #280 Email lookups scanned the whole table
- β³ β #266 Dragging a task told nobody
- β³ π #255 Article previews showed HTML codes
- β³ π #252 A link to a Knowledge article did not open
- β³ π #254 PDFs could not print Polish
- β³ π #250 Clicking a folder left the open article on screen
- β³ π« #260 The dashboard cog did nothing
- β³ π« #248 A department rule never matched on four routes
- β³ π A directory account could be matched to someone else
- β³ π« #244 Reply went to the assigned analyst
- β³ π Mailbox sign-in was not checked
- β³ π Four weaknesses around signing in
- β³ π A lookup answer could name any record
- β³ π« #227 A mailbox could not be deleted
- β³ π« #226 A picture on a note could not be closed
- β³ π» #222 The Assets page never loaded in 4.3.0
- β³ βοΈ #214 A "contains" condition did not match capitals
- β³ βοΈ #216 A workflow email from a chosen mailbox always failed
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96