-
-
Notifications
You must be signed in to change notification settings - Fork 27
Form Submission Actions Developer Guide
In one sentence: a form stores an ordered list of actions for each of three moments β submitted, approved, rejected β and the workflow engine runs them, so there is one automation engine in FreeITSM and the Forms module is a second front end onto it.
User-facing: Forms Β· Engine: Workflows Β· Approvals: Catalogue Request Approvals
Shipped under discussion #95 (dschipfel, Aug 2026) across changelog entries #1504β#1513.
The request was "let a form create a ticket". Reading the code first turned up that FreeITSM could already do it twice, in two places that had never met.
catalogueCreateTicketFromSubmission() |
WorkflowEngine::action_create_ticket() |
|
|---|---|---|
| Reachable when | a gated catalogue request is approved | any trigger, from the Workflows module |
| Configurable | not at all | fully |
| Requester | resolved by users.id
|
matched by email string, upserting a user |
Company (tenant_id) |
from the requester's own record | never set |
| Body | answers as a fully-escaped <table>
|
whatever the author typed |
| Provenance | none | none |
So the configurable path produced the worse ticket, and on a multi-company install left tickets unattributed. The wiki even documented the email-string bug in passing. That is the duplication #95 had to remove β not add a third implementation to.
The design conclusion: the Forms module needed a front end, not an engine.
The first plan was for the form editor to write a normal row into workflows with trigger_event = 'form.submitted' and a condition pinning form.id. That plan was wrong, and the reason is worth keeping:
π Forms are versioned. Editing one forks the leaf into a new row with a new id (
parent_form_idchains backwards,version_numbercounts forwards). A workflow pinned toform.id = 42simply stops matching the moment somebody edits form 42 into form 57 β silently, with the rule still sitting there looking active.
The evidence was already in the tree: FormsService::createVersion() copied title, description, is_active, is_portal_visible and not requires_approval / approver_id, so editing an approval-gated form had been quietly ungating it (see Β§8).
So the lists live on the form:
-- database/freeitsm.sql
`submission_actions` TEXT NULL,{
"submitted": [ { "type": "create_ticket", "args": { "subject": "β¦", "priority_id": 3 } } ],
"approved": [ β¦ ],
"rejected": [ β¦ ]
}JSON-in-TEXT for exactly the reason workflows.conditions / workflows.actions are: the schema must not need a migration every time the engine grows a new action kind.
Each entry is {type, args} β the same shape a stored workflow's actions use, because they are run by the same handlers.
| Stored | Means | Behaviour |
|---|---|---|
NULL |
never configured | pre-#95 behaviour β nothing on submit; an approval raises a ticket the hard-coded way |
[] |
somebody opened the panel and chose nothing | obeyed: an approval raises no ticket |
| a list | configured | the list runs instead of the hard-coded raise |
β This is what deleted the data migration. The original plan was to seed every gated form's
approvedlist with acreate_ticketaction and remove the hard-coded call. That plan had two failure modes:
- a form the seed missed silently stops raising tickets;
- a form that is seeded while the hard-coded path still runs raises two.
Encoding "absence means the old behaviour" has neither. Every form that existed before the upgrade is
NULLand keeps doing exactly what it did, so nothing is seeded and nothing can be half-migrated.The general lesson: when a migration has failure modes, look for the encoding in which absence means the old behaviour.
includes/services/forms.php
// FormsService::actionLists() - services/forms.php:553
public static function actionLists(PDO $conn, int $formId): array
{
$none = ['submitted' => null, 'approved' => null, 'rejected' => null];
$stmt = $conn->prepare("SELECT submission_actions FROM forms WHERE id = ?");
$stmt->execute([$formId]);
$raw = $stmt->fetchColumn();
if ($raw === false || $raw === null || trim((string)$raw) === '') return $none;
$decoded = json_decode((string)$raw, true);
if (!is_array($decoded)) return $none; // malformed == unconfigured
foreach (array_keys($none) as $key) {
if (isset($decoded[$key]) && is_array($decoded[$key])) {
$none[$key] = array_values($decoded[$key]);
}
}
return $none;
}Note the two deliberate choices: a malformed value degrades to null rather than throwing (a form must stay submittable even if its configuration is nonsense), and a missing key stays null rather than becoming [].
Writing goes through encodeActionLists() (services/forms.php:506), which normalises to the three known keys and β importantly β validates every action type against the engine:
if (!isset(WorkflowEngine::availableActions()[$type])) {
throw new ServiceError('validation', 'unknown_action', "Unknown action type: {$type}");
}
$list[] = ['type' => $type, 'args' => is_array($action['args'] ?? null) ? $action['args'] : []];
β οΈ Storing an unknown type would fail at run time, inside a form submission β where the person who typed it will never see the error. Validate at save.
Both branches of saveForm() (create and update) accept the key, and the update branch uses the module's incremental rule:
array_key_exists('submission_actions', $in)
? self::encodeActionLists($in['submission_actions'])
: ($current['submission_actions'] ?? null),
β οΈ Only touched when sent. An adapter that predates #95 β the REST API, an integration, a script that just renames a form β must not wipe a form's automation as a side effect of saving its title.
// services/forms.php:574 - the fork INSERT
"INSERT INTO forms (title, description, is_active, is_portal_visible,
requires_approval, approver_id, submission_actions,
created_by, modified_by, parent_form_id, version_number,
created_date, modified_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())"// NULL stays NULL - "never configured" must survive the copy, or every new
// version would look deliberately configured as empty.
$src['submission_actions'] ?? null,π A per-form setting left out of this INSERT is a setting that pressing Save deletes. That is not hypothetical β it is precisely what had been happening to
requires_approval(Β§8.1). Here it would mean a form quietly stopped raising tickets.
The action loop used to live inline inside runInner(). It was extracted so a form's list and a stored workflow's actions cannot diverge:
// WorkflowEngine::executeActionList() - engine.php:1690
private static function executeActionList(array $actions, array &$payload, array &$stepLog, bool $dryRun = false): array
{
foreach ($actions as $i => $action) {
$type = $action['type'] ?? '';
$args = $action['args'] ?? [];
if ($dryRun) { /* record would_run / would_args, continue */ }
try {
$result = self::executeAction($type, $args, $payload);
self::mergeStepResult($payload, $i, $result); // Β§5
$stepLog[] = ['kind' => 'action', 'index' => $i, 'type' => $type,
'status' => 'success', 'result' => $result];
} catch (Exception $e) {
$stepLog[] = ['kind' => 'action', 'index' => $i, 'type' => $type,
'status' => 'failed', 'error' => $e->getMessage()];
// Stop the chain. A later action almost always assumes the earlier
// one worked - emailing somebody about a ticket that was never
// raised is worse than not emailing them.
return ['failed', "Action {$i} ({$type}): " . $e->getMessage()];
}
}
return ['success', null];
}runInner() is now one line at that point:
} else {
[$status, $errorMessage] = self::executeActionList($actions, $payload, $stepLog, $dryRun);
}π Two copies of this loop would have drifted on the first change to error handling or chaining, and the difference would only ever have shown up as "it behaves differently from the form editor".
// WorkflowEngine::runActionList() - engine.php:1760
public static function runActionList(string $label, string $event, array $actions, array $payload): array
{
if (!$actions) return ['status' => 'skipped', 'execution_id' => null, 'steps' => []];
try {
$conn = connectToDatabase();
if (!array_key_exists('event', $payload)) $payload['event'] = $event;
$payload = self::enrichPayloadForTemplates($conn, $payload);
$insert = $conn->prepare(
"INSERT INTO workflow_executions
(workflow_id, workflow_name, trigger_event, trigger_payload, status, is_dry_run, started_datetime)
VALUES (NULL, ?, ?, ?, 'running', 0, UTC_TIMESTAMP())"
);
$insert->execute([$label, $event, json_encode($payload)]);
$execId = (int)$conn->lastInsertId();
[$status, $errorMessage] = self::executeActionList($actions, $payload, $stepLog, false);
// β¦UPDATE the execution row with status / step_log / error_messageβ¦
} catch (Throwable $e) {
// An engine fault must not take down a form submission.
error_log('Form action list failed (' . $label . '): ' . $e->getMessage());
$status = 'failed';
}
return ['status' => $status, 'execution_id' => $execId, 'steps' => $stepLog];
}π
workflow_executions.workflow_idis nullable and carries aworkflow_namesnapshot β a design that already existed so a run could outlive its parent. A form's run reuses it with no parent, labelledForm: Guest Wi-Fi Access β when submitted. Without this a form's automation would be the one kind of automation nobody could audit, and "it didn't do anything and I can't see why" is the worst failure mode automation has.
β οΈ Never throws. The caller is a form submission or an approval decision, and neither may fail because a rule somebody configured is wrong.
Before #95 every action ran blind: runInner() put each result into step_log and nowhere else. A workflow could raise a ticket and its very next action had no way to name it β and not as two workflows either, because create_ticket dispatches no event of its own. So "raise a ticket, then email the requester about it" β the single most obvious pair of actions in the product β was unbuildable.
// WorkflowEngine::mergeStepResult() - engine.php:1820
private static function mergeStepResult(array &$payload, int $index, $result): void
{
if (!is_array($result) || $result === []) return;
$payload['steps'][$index + 1] = $result;
$payload['last'] = $result;
}| Merge code | Reaches |
|---|---|
{{last.ticket_id}} |
the action immediately before this one |
{{steps.1.ticket_number}} |
a particular action, by the number shown beside it |
π Merged under their OWN keys, never into the payload's natural namespace. Writing a new ticket's id over
ticket.idwould silently repoint every later{{ticket.*}}on a ticket-triggered workflow at a different ticket from the one that fired it β a workflow that had been correct for a year would quietly start emailing about the wrong record.
β οΈ stepsis one-based, matching the number the editor prints beside each action. A merge code that disagreed with the number on screen would be wrong in the one place people copy it from.
β οΈ Nothing merges on a dry run (nothing ran, so a later action's preview shows the code unresolved β which is honest) or after a failed action (the chain stops anyway).
Both prefixes are advertised for every trigger, since what an earlier action produced depends on the actions chosen, not on what fired the run:
// engine.php:809
public static function variablePrefixes(string $trigger): array
{
$prefixes = ['last.', 'steps.'];
if (in_array($trigger, self::SUBMISSION_FIELD_TRIGGERS, true)) {
$prefixes[] = 'submission.fields.';
}
return $prefixes;
}action_create_ticket() now delegates whenever the payload carries a submission:
// engine.php:2172
$submissionId = (int)(self::dotGet($payload, 'submission.id') ?? 0);
if ($submissionId > 0) {
require_once __DIR__ . '/../../includes/catalogue_approvals.php';
$conn = connectToDatabase();
return catalogueRaiseTicketFromSubmissionId($conn, $submissionId, [
// Blank means "not configured", so it must not reach the shared path as
// an override - that is what lets the submission supply the default.
'subject' => $subject !== '' ? $subject : null,
'body_html' => $body !== '' ? nl2br(htmlspecialchars($body)) : null,
'priority_id' => $priorityId,
'department_id' => $departmentId,
'ticket_type_id' => $typeId,
'assigned_analyst_id' => $assignedAnalystId,
'from_email' => $fromEmail,
'from_name' => $fromName,
'audit_note' => 'Raised from a form submission by a workflow',
]);
}
// β¦otherwise the original behaviour, untouchedβ¦π THE RULE: an override always wins, and the submission fills in whatever was left blank. That one sentence is what lets a single function serve the approval path (which configures nothing) and a form's action list (which may configure everything).
β οΈ Therequire_oncemust be lazy β inside the handler.includes/catalogue_approvals.phpandincludes/services/forms.phpboth require the engine at file level; a top-level require back would close the ring. Same idiom the engine already uses for the notifications router and the search indexer.
β οΈ Anything with no submission in its payload β a fan-out fromticket.created, say β keeps the original behaviour bit for bit.
The shared implementation gained an $overrides parameter and an anonymous-submitter fallback:
// catalogue_approvals.php:211
function catalogueCreateTicketFromSubmission(PDO $conn, array $sub, array $overrides = []): array {
$userId = (int)($sub['submitted_by_user_id'] ?? 0);
$fallbackEmail = trim((string)($overrides['from_email'] ?? ''));
// A portal catalogue request always carries the account that raised it - the
// good case. A form filled in by somebody not signed in has only the address
// they typed. Anonymous submissions were the ONLY case the workflow action
// ever handled, which is why it produced the weaker ticket even for portal
// requests.
if (!$userId && $fallbackEmail !== '') { /* β¦resolve or create the userβ¦ */ }
if (!$userId) throw new Exception('This request has no requester to raise a ticket for');
// β¦company from the requester's own tenant_id, NULL (triage) on a
// multi-company install when they have noneβ¦
$subject = trim((string)($overrides['subject'] ?? ''));
if ($subject === '') $subject = trim((string)($sub['form_title'] ?? '')) ?: 'Service request';
// A blank body means "show me what they asked for", not "send an empty
// ticket" - so the escaped answer table is the DEFAULT, not a special case
// somebody has to know to ask for.
$bodyHtml = (string)($overrides['body_html'] ?? '');
if (trim($bodyHtml) === '') {
$bodyHtml = catalogueSubmissionBodyHtml($conn, (int)$sub['id'],
trim((string)($sub['form_title'] ?? '')) ?: 'a form');
}
// β¦
}
β οΈ The body heading takes the form title, never$subject. Once a subject override existed, passing$subjectmade the opening line repeat the subject already at the top of the ticket, losing the one thing that line is for. (Found by Ed reading a real ticket, not a test one.)
Every ticket raised this way now writes provenance:
"INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
VALUES (?, NULL, 'Ticket Created', NULL, ?, UTC_TIMESTAMP())"
// analyst_id NULL is the established marker for "the system did this, not a person".send_email took its mailbox from the ticket, so it could not acknowledge anything that was not yet a ticket β which is exactly the confirmation somebody expects the moment they submit a form.
// engine.php:1965
$ticketId = self::argInt($args, 'ticket_id', $payload);
$mailboxId = self::argInt($args, 'mailbox_id', $payload);
if (!$ticketId && !$mailboxId) {
throw new Exception('Either a ticket or a mailbox to send from is required');
}// An explicitly chosen mailbox wins over the ticket's own.
if ($mailboxId) {
$mb = $conn->prepare('SELECT * FROM target_mailboxes WHERE id = ?');
$mb->execute([$mailboxId]);
$mailbox = $mb->fetch(PDO::FETCH_ASSOC) ?: null;
if (!$mailbox) throw new Exception("Mailbox not found: {$mailboxId}");
} else {
$mailbox = templateGetMailboxForTicket($conn, $ticketId);
if (!$mailbox) throw new Exception('Ticket has no associated mailbox β cannot send');
}$fullBody = $ticketRef !== ''
? buildTemplateEmailBody($body, $ticketRef) // with the reply marker
: self::plainEmailBody($body); // without it
β οΈ No ticket means no reply marker.buildTemplateEmailBody()appends "Please reply above this line" over an[*** SDREF:β¦ ***]token, which is what threads a reply back onto the ticket. With no ticket the token is empty, so the trailer would invite a reply that lands nowhere.plainEmailBody()is the same styling without it.
β οΈ templateSaveSentEmail()is skipped without a ticket β there is no conversation to file the copy against β butemailLogSent()still records the send, so a standalone message is auditable under Email send log.
β οΈ mailboxis an action lookup only, never a condition field: nothing dispatches a mailbox id in a payload, so there would be nothing to compare against. The table istarget_mailboxesand it has nois_activecolumn.
All three failed silently, which is how they survived. None had been reported.
createVersion()'s INSERT named title, description, is_active, is_portal_visible β and not requires_approval / approver_id. requires_approval defaults to 0, the catalogue lists leaves, so the ungated copy became the one the portal offered. Two behaviours changed at once and neither announced itself: sign-off stopped being required, and β the approve path being the only thing that raises the ticket β requests stopped becoming tickets.
β οΈ Not retrospectively repairable. A form edited before the fix lost the value at that moment; there is nothing to recover it from. Any install that used approvals needs its forms checked by hand.
catalogue_request.submitted / .approved / .rejected were dispatched from the day approvals shipped, and none was in availableTriggers(). dispatch() matches on trigger_event, and the editor can only offer what the catalogue lists β so all three matched zero workflows for their entire life. The approver was never told a request was waiting.
π A
dispatch()call without a catalogue entry is an event that can never be heard, and nothing anywhere reports the mismatch. Worth checking whenever a new event is added.
β οΈ The event is.rejected, not.declined. The name is built as'catalogue_request.' . $decisionand$decisionis validated against['approved','rejected']β the word the column, the API contract and the UI button all use.
form.id had an entry in FIELD_LOOKUP_TABLES β reading a column called name. The forms table has title. The query threw, availableValuesForField() caught it and returned null, and the editor drew an empty tick-list, which is indistinguishable from a field that was never meant to have one. So "Form" was offered as something to match on and could never be filled in, and the only rule anyone could save fired for every form on the install.
'form.id' => [
'table' => 'forms',
'label_col' => 'title',
'where' => 'is_active = 1 AND id NOT IN (SELECT parent_form_id FROM forms WHERE parent_form_id IS NOT NULL)',
'order' => 'title',
],β A broken lookup is indistinguishable from a missing one, because the catch swallows the difference. Sweep the whole map against the database periodically β all 29 pass as of #95.
β οΈ Leaves only. Frozen versions keep their titles, so listing every row offers the same form name six times with no way to tell which is current. It follows that a condition pinned toform.idstops matching once that form is versioned β another reason the form's own list belongs on the form.
// FormsService::submitForm() - after the dispatch, after the commit
if ($gateApproverId === null) {
try {
$lists = self::actionLists($conn, $formId);
if (!empty($lists['submitted'])) {
WorkflowEngine::runActionList(
'Form: ' . $form['title'] . ' β when submitted',
'form.submitted',
$lists['submitted'],
$payload
);
}
} catch (Exception $e) {
error_log('Form action list error on submission: ' . $e->getMessage());
}
}
β οΈ A gated request runs nothing here, for the same reason it fires a different event: its actions belong to the approval decision, and raising a ticket at submission time would step straight over the gate.π It runs inside
FormsService::submitForm()β the single write path the UI adapter, the portal and REST API v1 all funnel through β so all three behave identically without any of them knowing the feature exists. See Service Layer Architecture.
$formLists = catalogueFormActionLists($conn, (int)$sub['form_id']);
$decisionList = $formLists[$decision] ?? null;
$listOwnsTheTicket = ($decision === 'approved' && $decisionList !== null);
$conn->beginTransaction();
try {
if ($decision === 'approved') {
// When the form owns this, the ticket is raised by its own action list
// AFTER the commit. Doing it here would put an email send inside a
// transaction, and a rolled-back email is still an email somebody
// received.
if (!$listOwnsTheTicket) {
[$ticketId, $ticketNumber] = catalogueCreateTicketFromSubmission($conn, $sub);
}
// β¦UPDATE form_submissions β¦ ticket_id = ? β¦
}
$conn->commit();
} catch (Exception $e) { /* rollback + rethrow */ }and afterwards:
if ($decisionList) {
$answers = catalogueSubmissionAnswerMap($conn, $submissionId);
$run = WorkflowEngine::runActionList(
'Form: ' . $sub['form_title'] . ' β when ' . $decision,
'catalogue_request.' . $decision,
$decisionList,
[ 'form' => β¦, 'request' => β¦, 'submission' => ['fields' => $answers['fields'], β¦] ]
);
// If the list raised a ticket, the submission must point at it - that column
// is what the requester's "Your requests" panel reads, and a request showing
// no ticket after one was raised looks like a failure.
foreach ($run['steps'] as $step) {
if (($step['type'] ?? '') === 'create_ticket' && !empty($step['result']['ticket_id'])) {
$conn->prepare("UPDATE form_submissions SET ticket_id = ? WHERE id = ?")
->execute([(int)$step['result']['ticket_id'], $submissionId]);
break;
}
}
}catalogueFormActionLists() (catalogue_approvals.php:389) is a lazy wrapper β the require_once of services/forms.php sits inside the function for the same ring-avoidance reason as Β§6 β and returns all-null on any failure, because unreadable configuration must never block an approval decision.
forms/edit/index.php exports the catalogue straight from the engine:
require_once '../../workflow/includes/engine.php';
$formActionDefs = WorkflowEngine::availableActions();
$formActionLookups = [];
foreach ($formActionDefs as $def) {
foreach (($def['args'] ?? []) as $argSpec) {
if (is_array($argSpec) && ($argSpec['type'] ?? '') === 'lookup' && !empty($argSpec['lookup'])) {
$lk = $argSpec['lookup'];
if (!isset($formActionLookups[$lk])) {
$formActionLookups[$lk] = WorkflowEngine::availableActionLookup($lk) ?? [];
}
}
}
}window.FA_ACTION_DEFS = <?php echo json_encode($formActionDefs, β¦); ?>;
window.FA_ACTION_LOOKUPS = <?php echo json_encode($formActionLookups, β¦); ?>;π What actions exist, what arguments each takes and which widget each argument gets all come from the engine. A second description here would drift the first time an action gained an option, and the two editors would then disagree about what the same feature can do. This mirrors
workflow/editor.phpexactly.β The list renderer is deliberately not shared with
assets/js/workflow-editor.jsβ that one is bound to the canvas node model. Sharing the data is the point; sharing the drawing code would mean loading a canvas to render a list.
faArgControl() picks the widget from the same arg spec the canvas uses (lookup β <select>, textarea, number, checkbox, else text), and only shows the merge-code hint where supports_vars is set β rather than on every field as decoration.
The payload keeps the null / [] distinction all the way to the wire:
function faPayload() {
const out = {};
FA_WHENS.forEach(w => { if (faActions[w] !== null) out[w] = faActions[w]; });
return Object.keys(out).length ? out : null;
}const faOut = faPayload();
if (faOut !== null) payload.submission_actions = faOut;- The submitted heading becomes "When submitted (before approval)" when the form is gated. "When submitted" and "when submitted, before anyone has approved it" are different promises, and the name does most of the work.
- The gate warning appears only when a ticket-raising action actually sits in the submitted list of a gated form β the one configuration that defeats the gate. Warning on every action would train people to ignore it.
- On a gated form with nothing configured, the approved section says a ticket gets raised anyway. Otherwise an empty section reads as "nothing happens", when in fact the hard-coded raise is about to fire.
Approved and rejected are dimmed, not hidden, without a gate: hiding leaves no clue the capability exists, and configuration you cannot see is configuration you cannot fix.
π΄ The editor's state lives in top-level
letbindings, which are NOTwindowproperties. The first headless harness died onw.faActionsbeing undefined. Drive the page through its exported functions (faAddAction,faSetType,faSetArg,faRemoveAction,faPayload) and the rendered DOM β the surface a person actually uses, which made the test better. Same trap asPMin the Process Mapper.
The harness recipe that worked, and is worth reusing:
<!-- a temporary file in the web root, deleted straight after -->
<script>
document.cookie = 'PHPSESSID=<forged>; path=/'; // same origin, so it reaches the iframe
const f = document.createElement('iframe');
f.src = '/freeitsm-app/forms/edit/?id=186';
f.onload = () => setTimeout(() => {
const w = f.contentWindow, d = f.contentDocument;
d.getElementById('tabActions').click();
w.faAddAction('submitted');
w.faSetType('submitted', 0, 'create_ticket');
/* β¦assert against w.faPayload() and the DOMβ¦ */
document.getElementById('out').textContent = results.join('\n');
}, 1500);
document.body.appendChild(f);
</script>chrome --headless=old --disable-gpu --no-sandbox --virtual-time-budget=15000 \
--dump-dom "http://localhost/freeitsm-app/_fa_harness.html"Server side, the assertion that actually matters is an existing gated form, untouched, still raising exactly one ticket β tested against a form that already existed rather than one the script created.
β οΈ Assert the rule, not a value. One assertion checked that the raised ticket's company "was not null" and failed β correctly, because a requester with no company on a multi-company install lands onNULLfor triage, which is what the portal's own new-ticket path does. It was rewritten to pick a requester who has a company, so the carry is genuinely exercised.
-
create_tickethas no status, team or tags argument. dschipfel asked for all three in #95. - The condition Field dropdown prints raw paths (
form.id) while the merge-code picker humanises them (Form Β· Id). Same data, only one got the polish. -
form.submittedflattens a checkbox answer raw (1/0); the approval events renderYes/NoviacatalogueAnswerText(). Unifying changes what already-built rules receive, so it is a deliberate decision rather than a tidy-up. - The action lists are not yet exposed through REST API v1 β the service accepts them, the API resource does not surface them.
- Forms β the module, and the What happens next tab
- Workflows β the engine, its triggers, actions and merge codes
- Catalogue Request Approvals β the gate, and the storage contract
- Service Layer Architecture β why this runs in the service and not an adapter
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- 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: 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
- 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
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« 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: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ 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
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π 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
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ 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
- β³ π’ 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