Skip to content

Forms on Tickets Developer Guide

Ed Mozley edited this page Nov 11, 2026 · 1 revision

πŸ› οΈ Forms on tickets - Developer Guide (1 of 2): the data and the engine

How a form from the Forms module is attached to a ticket, filled in on it, linked to it, and required before it closes. Built for #272 (5.0.0). User guide: Forms on tickets.

  • Page 1 (this page): the files, the schema, how one engine serves tasks and tickets (TaskForms / TicketForms), submitting and the retry key, the Forms evidence guard, the close gate, merge, delete and the analyst lifecycle.
  • Page 2: the browser endpoint, the fill page, the ticket's Forms panel, the workspace Work tab, right-click Add form, the setting, the case report, the REST API and the tests.

The one-line version: forms on tickets are forms on tasks with a different host. TaskForms (includes/services/task_forms.php, built for #190) names its host only through class constants and hooks, and TicketForms extends TaskForms overrides just those. One engine, one set of rules: newest version, the shared draft with its revision check, the retry key, corrections that keep the old submission, the answers rule, the company scope. Nothing was copied.


1. The files

πŸ—„οΈ schema Β· βš™οΈ service Β· πŸ”Œ endpoint Β· πŸ–₯️ UI Β· πŸ”— cross-module Β· πŸ§ͺ tests

File 🎨 What happens there
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php (generated) πŸ—„οΈ ticket_forms; form_submissions.for_ticket_id with its own index and retry-key unique index
includes/services/task_forms.php βš™οΈ TaskForms made host-aware; class TicketForms extends TaskForms
includes/services/forms.php πŸ”— submitForm(…, $link) writes for_ticket_id; assertNotTaskEvidence() refuses deleting what a ticket relies on (ticket_evidence)
includes/services/checklists.php πŸ”— assertClosureAllowed() checks outstanding ticket forms first; recordClosureOverride() records warn-mode ones
includes/capabilities.php, tickets/settings/manifest.php πŸ”— Cap::TICKETS_FORM_ANSWERS; the tickets_forms_enabled key on the Checklists tab
includes/ticket_merge.php πŸ”— MERGE_MOVE_DEDUPE['ticket_forms'] = 'form_id'
api/tickets/permanently_delete_ticket.php, empty_trash.php, api/system/debug-tools/D002_delete_ticket.php πŸ”— delete the requirement rows, keep the submissions
includes/analyst_lifecycle.php πŸ”— ['ticket_forms', 'added_by_id', 'history']
api/system/db_verify.php πŸ”— seeds tickets_forms_enabled = '0'
api/tickets/forms.php πŸ”Œ page 2
forms/fill.php πŸ–₯️ page 2
assets/js/ticket-forms.js, assets/js/inbox.js, tickets/index.php πŸ–₯️ page 2
tickets/settings/index.php, api/tickets/save_checklist_settings.php πŸ–₯️ page 2
includes/case_report.php, includes/case_report_page.php πŸ”— page 2
api/v1/resources/tickets.php, lib/routes.php, lib/permissions.php, lib/modules.php, spec.json πŸ”Œ page 2
tests/ticket-forms.php πŸ§ͺ page 2

2. The schema

ticket_forms - one row per form a ticket needs

A column-for-column twin of task_forms, so the engine's SQL works on either table by name:

CREATE TABLE IF NOT EXISTS `ticket_forms` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `ticket_id` INT NOT NULL,
    `form_id` INT NOT NULL,                     -- the exact form VERSION this ticket is filled on
    `title` VARCHAR(255) NOT NULL,
    `is_required` TINYINT(1) NOT NULL DEFAULT 1,
    `requirement_mode` ENUM('warn','block') NOT NULL DEFAULT 'block',
    `submission_id` INT NULL,
    `draft_json` LONGTEXT NULL,
    `draft_by_id` INT NULL,
    `draft_by_name` VARCHAR(150) NULL,
    `draft_datetime` DATETIME NULL,
    `revision` INT NOT NULL DEFAULT 0,
    `sort_order` INT NOT NULL DEFAULT 0,
    `added_by_id` INT NULL,
    `added_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uq_ticket_forms_form` (`ticket_id`, `form_id`),
    KEY `idx_ticket_forms_submission` (`submission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Column Meaning
form_id the exact form version it is filled on: add() resolves the newest version in the chain
title the form's title when added, kept if the form is later renamed
submission_id the submission that currently meets it; a correction replaces it
draft_json, draft_by_* one shared draft per ticket form, not one per person, so a colleague can carry on
revision bumped by every draft save and submit; a write that names a stale revision is a conflict
UNIQUE (ticket_id, form_id) the same form once per ticket

πŸ”΄ No foreign keys, as for task_forms. A cascade would delete the evidence along with the thing it is evidence about.

form_submissions.for_ticket_id

    -- GH #272: a submission made FOR a ticket (a form on the ticket - ticket_forms), with the
    -- ticket's company in tenant_id as for a task. NOT ticket_id, which means "the ticket
    -- raised FROM this submission" (a catalogue request).
    `for_ticket_id`     INT NULL,
    …
    KEY `idx_form_submissions_for_ticket` (`for_ticket_id`),
    UNIQUE KEY `uq_form_submissions_task_ref` (`task_id`, `client_reference`),
    UNIQUE KEY `uq_form_submissions_ticket_ref` (`for_ticket_id`, `client_reference`),
  • Why not ticket_id. form_submissions.ticket_id already means "the ticket this submission raised" (a catalogue request becomes a ticket). Reusing it would make every such ticket look like it had a form on it, and the reverse.
  • Its own retry key. client_reference is unique per ticket, so a task and a ticket can never collide on the same reference.
  • tenant_id is the ticket's company at submit time, so the Forms module can scope the submission (forms themselves are install-wide).

includes/db_verify_schema.php has the same columns, so Database Verify creates them on existing installs. The indexes come from php scripts/gen_db_verify_indexes.php.


3. One engine, two hosts

Before #272, TaskForms hard-coded task_forms, task_id, tasks_forms_enabled and the task permission. Now every host-specific piece is a constant or a protected static hook, and every method reaches them through static::, which is PHP's late static binding. So a method defined once in TaskForms runs with TicketForms's values when it is called as TicketForms::….

class TaskForms
{
    /** The requirement table. */
    protected const TABLE = 'task_forms';
    /** Its column naming the host. */
    protected const FK = 'task_id';
    /** The column on form_submissions linking a submission to its host. */
    protected const SUB_COL = 'task_id';
    /** The system_settings switch. */
    protected const SETTING = 'tasks_forms_enabled';
    /** The host's name in messages. */
    protected const NOUN = 'task';

    protected static function answersCap(): ?Cap { return class_exists('Cap') ? Cap::TASKS_FORM_ANSWERS : null; }
    protected static function hostTenant(PDO $conn, int $hostId): ?int { /* SELECT tenant_id FROM tasks … */ }
    protected static function hostSummary(PDO $conn, int $hostId): array { /* id, title, scheduled_date, due_date */ }
    protected static function record(PDO $conn, int $hostId, string $type, ActorContext $ctx, ?string $from, ?string $to, array $detail): void
    {
        TaskHistory::record($conn, $hostId, $type, $ctx, $from, $to, $detail);   // task_events
    }
    protected static function afterSubmit(PDO $conn, ActorContext $ctx, int $hostId, int $submissionId): void { /* Starters & leavers */ }
    protected static function guardWrite(PDO $conn, ActorContext $ctx, int $taskId): void { /* Tasks write, task visible, #181 lock */ }
    // … every public method below uses static::TABLE, static::FK, static::SUB_COL, static::guardWrite() …
}
class TicketForms extends TaskForms
{
    protected const TABLE   = 'ticket_forms';
    protected const FK      = 'ticket_id';
    protected const SUB_COL = 'for_ticket_id';
    protected const SETTING = 'tickets_forms_enabled';
    protected const NOUN    = 'ticket';

    protected static function answersCap(): ?Cap
    {
        return class_exists('Cap') && defined('Cap::TICKETS_FORM_ANSWERS') ? Cap::TICKETS_FORM_ANSWERS : null;
    }

    protected static function hostTenant(PDO $conn, int $hostId): ?int
    {
        $t = $conn->prepare("SELECT tenant_id FROM tickets WHERE id = ?");
        $t->execute([$hostId]);
        $v = $t->fetchColumn();
        return $v !== false && $v !== null ? (int)$v : null;
    }

    protected static function hostSummary(PDO $conn, int $hostId): array
    {
        $t = $conn->prepare("SELECT id, ticket_number, subject FROM tickets WHERE id = ?");
        $t->execute([$hostId]);
        $r = $t->fetch(PDO::FETCH_ASSOC) ?: [];
        $num = (string)($r['ticket_number'] ?? '');
        return ['id' => (int)($r['id'] ?? 0), 'title' => trim(($num !== '' ? $num . ' ' : '') . (string)($r['subject'] ?? '')),
                'scheduled_date' => null, 'due_date' => null];
    }

    /** A ticket's history is ticket_audit: "Form added: Laptop handover". NULL analyst = no person. */
    protected static function record(PDO $conn, int $hostId, string $type, ActorContext $ctx, ?string $from, ?string $to, array $detail): void
    {
        $label = ['form_added' => 'Form added', 'form_removed' => 'Form removed',
                  'form_submitted' => 'Form submitted', 'form_replaced' => 'Form resubmitted'][$type] ?? 'Form';
        try {
            $conn->prepare("INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
                            VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP())")
                 ->execute([$hostId, $ctx->actorId > 0 ? $ctx->actorId : null, $label,
                            $type === 'form_removed' ? $from : null, $type === 'form_removed' ? null : $to]);
        } catch (Throwable $e) {
            error_log('[ticket forms] history for ticket ' . $hostId . ': ' . $e->getMessage());
        }
    }

    protected static function afterSubmit(PDO $conn, ActorContext $ctx, int $hostId, int $submissionId): void
    {
        try { $conn->prepare("UPDATE tickets SET updated_datetime = UTC_TIMESTAMP() WHERE id = ?")->execute([$hostId]); }
        catch (Throwable) { /* nothing else waits on it */ }
    }

    /** Tickets write access and the ticket visible (and there). */
    protected static function guardWrite(PDO $conn, ActorContext $ctx, int $ticketId): void
    {
        if ($ctx->actorId > 0 && function_exists('analystCanWriteModule') && !analystCanWriteModule($conn, $ctx->actorId, 'tickets')) {
            throw new ServiceError('forbidden', 'read_only', 'Read only access to Tickets cannot change a ticket\'s forms.');
        }
        $s = $conn->prepare("SELECT 1 FROM tickets WHERE id = ?");
        $s->execute([$ticketId]);
        if (!$s->fetchColumn() || ($ctx->actorId > 0 && function_exists('analystCanAccessTicket') && !analystCanAccessTicket($conn, $ctx->actorId, $ticketId))) {
            throw new ServiceError('not_found', 'not_found', 'Ticket not found.');
        }
    }

    public static function forTicket(PDO $conn, int $ticketId, int $viewerId, bool $withAnswers = false): array
    {
        return static::forHost($conn, $ticketId, $viewerId, $withAnswers);
    }
}
Hook Task Ticket
answersCap() Cap::TASKS_FORM_ANSWERS Cap::TICKETS_FORM_ANSWERS, so the task permission does not reveal ticket answers
record() task_events via TaskHistory a ticket_audit row: Form added / removed / submitted / resubmitted
afterSubmit() Starters & leavers picks up a case's answers stamps tickets.updated_datetime, so live updates see it
guardWrite() Tasks write access, the task visible, the #181 completed-task lock Tickets write access, and the ticket exists and is visible
  • Why guardWrite() reads the row first. analystCanAccessTicket() is a company check that is true for any id on a single-company install. That was found in #277 and is reused here.
  • Renamed for the generic case: forTask() is kept as an alias of forHost(), and deleteForTasks() of deleteForHosts(), so every existing caller of the task names still works. submissionScopeSql() scopes both kinds of linked submission (task_id and for_ticket_id) by company in the Forms module's lists.

πŸ”΄ Never copy a method into TicketForms. Change it in TaskForms, for both hosts. The class comment says so for the next person.


4. Submitting: one transaction, the retry key, the revision

submit() is inherited unchanged. With TicketForms, static::SUB_COL is for_ticket_id:

public static function submit(PDO $conn, ActorContext $ctx, int $hostFormId, int $revision, string $clientRef, array $data): array
{
    require_once __DIR__ . '/forms.php';
    $r = static::row($conn, $hostFormId);
    $hostId = static::hostOf($r);
    $clientRef = substr(preg_replace('/[^A-Za-z0-9_.:-]/', '', $clientRef), 0, 64);

    // A retry of a submit that DID go through: hand back what it made.
    if ($clientRef !== '') {
        $q = $conn->prepare("SELECT id FROM form_submissions WHERE " . static::SUB_COL . " = ? AND client_reference = ?");
        $q->execute([$hostId, $clientRef]);
        $existing = $q->fetchColumn();
        if ($existing) {
            return ['submission_id' => (int)$existing, 'revision' => (int)$r['revision'], 'replayed' => true];
        }
    }

    static::guardWrite($conn, $ctx, $hostId);
    static::guardFill($conn, $ctx);
    $previous = $r['submission_id'] !== null ? (int)$r['submission_id'] : null;
    $table = static::TABLE;

    $within = function (PDO $conn, int $submissionId) use ($hostFormId, $revision, $previous, $table) {
        $up = $conn->prepare("UPDATE $table
                                 SET submission_id = ?, draft_json = NULL, draft_by_id = NULL, draft_by_name = NULL,
                                     draft_datetime = NULL, revision = revision + 1
                               WHERE id = ? AND revision = ?");
        $up->execute([$submissionId, $hostFormId, $revision]);
        if ($up->rowCount() !== 1) {
            throw static::conflict($conn, $hostFormId);   // rolls the submission back with it
        }
        if ($previous) {
            $conn->prepare("UPDATE form_submissions SET superseded_by_id = ? WHERE id = ?")->execute([$submissionId, $previous]);
        }
    };

    $submissionId = FormsService::submitForm($conn, $ctx, (int)$r['form_id'], $data, null, [
        static::SUB_COL    => $hostId,
        'tenant_id'        => static::hostTenant($conn, $hostId),
        'client_reference' => $clientRef !== '' ? $clientRef : null,
        'within'           => $within,
    ]);

    static::record($conn, $hostId, $previous ? 'form_replaced' : 'form_submitted', $ctx, /* … */);
    static::afterSubmit($conn, $ctx, $hostId, $submissionId);
    return ['submission_id' => $submissionId, 'revision' => $revision + 1, 'replayed' => false];
}
  • The retry key. A second submit with the same client_reference returns the submission already made, with replayed: true. The fill page and the REST API both send one, so a double click or a network retry makes one submission, not two.
  • One transaction. FormsService::submitForm() validates the answers exactly as the Forms page does, inserts the submission, and calls within inside its own transaction.
  • The revision check. within claims the requirement only if its revision is still the one the person was shown. If someone else saved meanwhile, it throws TaskFormConflict, which rolls the submission back too: a lost race leaves no orphan submission. The conflict carries who saved, when, and their answers, so the fill page can show them.
  • A correction. The new submission replaces the old on the requirement, and the old gets superseded_by_id. Both stay in Forms and keep for_ticket_id.

The link inside submitForm():

// includes/services/forms.php - submitForm()
if (!empty($link['task_id'])) {
    $cols .= ', task_id, tenant_id, client_reference';
    $vals .= ', ?, ?, ?';
    array_push($args, (int)$link['task_id'], $link['tenant_id'] ?? null, $link['client_reference'] ?? null);
} elseif (!empty($link['for_ticket_id'])) {
    // GH #272: made FOR a ticket (a form on it) - never ticket_id, which is "raised from".
    $cols .= ', for_ticket_id, tenant_id, client_reference';
    $vals .= ', ?, ?, ?';
    array_push($args, (int)$link['for_ticket_id'], $link['tenant_id'] ?? null, $link['client_reference'] ?? null);
}
$conn->prepare("INSERT INTO form_submissions ($cols) VALUES ($vals)")->execute($args);

The form.submitted workflow payload gains for_ticket: {id} when there is one, so a workflow can tell a ticket's form apart from a public submission.


5. Forms keeps the evidence

A submission a ticket relies on, or a form a ticket still needs, cannot be deleted from the Forms module. assertNotTaskEvidence() (called from deleteSubmission() and deleteForm()) gained a ticket half:

// GH #272: the same for a ticket's forms - a submission made for a ticket is its
// record, and a form a ticket needs cannot vanish from under it. Its own try: an
// install that has the task columns may not have run Database Verify for these.
try {
    $q = $conn->prepare("SELECT COUNT(*) FROM form_submissions WHERE for_ticket_id IS NOT NULL AND $where");
    $q->execute($args);
    $ticketLinked = (int)$q->fetchColumn();
    $ticketNeeded = 0;
    if (str_starts_with($where, 'form_id')) {
        $r = $conn->prepare("SELECT COUNT(*) FROM ticket_forms WHERE $where");
        $r->execute($args);
        $ticketNeeded = (int)$r->fetchColumn();
    }
} catch (Throwable) { /* before Database Verify: nothing can be linked yet */ }
// … throws ServiceError('validation', 'ticket_evidence', …) when either is non-zero

$where is a fixed developer string ('id = ?' or "form_id IN ($place)"), never user input.


6. The close gate

Every way a ticket closes (the reading pane, bulk actions, workflows, the REST API) goes through ChecklistsService::assertClosureAllowed() before the status is written. Ticket forms are checked first, and independently of the Checklists module switch, because forms have their own switch:

public static function assertClosureAllowed(PDO $conn, int $ticketId, ?int $tenantId = null): void
{
    // GH #272: a required form on the ticket that has no submission, set to block.
    // First, and independent of the Checklists switch below - forms have their own
    // ("Forms on tickets"), and TicketForms::outstanding() is empty while it is off.
    // Same rule as a task's forms: the form's own warn / block, with the company's
    // "block all" floor (effectiveClosureMode).
    $formsBlocking = [];
    foreach (self::ticketFormsOutstanding($conn, $ticketId) as $f) {
        if (self::effectiveClosureMode($conn, $f['mode'], $tenantId) === 'block') $formsBlocking[] = $f['title'];
    }
    if ($formsBlocking) {
        throw new ServiceError('validation', 'forms_outstanding',
            'This ticket cannot be closed until its required forms are submitted: ' . implode(', ', $formsBlocking));
    }
    // … then the checklist rules, unchanged
}

/** Required forms on a ticket with no submission (GH #272) - empty while "Forms on tickets" is off. Never throws. */
private static function ticketFormsOutstanding(PDO $conn, int $ticketId): array
{
    try {
        require_once __DIR__ . '/task_forms.php';
        return TicketForms::outstanding($conn, $ticketId);
    } catch (Throwable) {
        return [];
    }
}

outstanding() is inherited. Its SQL is the same for both hosts, and it returns nothing while the switch (or the Forms module) is off, so a form added before someone switched the feature off can't hold a ticket open that nobody can fill:

public static function outstanding(PDO $conn, int $hostId): array
{
    if (!static::enabled($conn) || !static::columnsReady($conn)) return [];
    try {
        $st = $conn->prepare("SELECT title, requirement_mode FROM " . static::TABLE . "
                               WHERE " . static::FK . " = ? AND is_required = 1 AND submission_id IS NULL
                               ORDER BY sort_order, id");
        $st->execute([$hostId]);
        return array_map(function ($r) { return ['title' => (string)$r['title'], 'mode' => (string)$r['requirement_mode']]; },
                         $st->fetchAll(PDO::FETCH_ASSOC));
    } catch (Throwable) {
        return [];   // completing / closing must not start failing on a part-upgraded install
    }
}
  • Block vs warn. A form set to warn doesn't refuse the close. recordClosureOverride() (called after the close) writes the internal note naming it, alongside any skipped checklist steps.
  • The company's floor. Checklists β†’ Block every ticket with outstanding steps (effectiveClosureMode()) upgrades warn to block for forms too.
  • Workflows. The workflow action set_ticket_status calls assertClosureAllowed() itself, because it writes the status directly. A blocked close fails that step and says why.

7. Merge, delete and the analyst lifecycle

// includes/ticket_merge.php - rows moved to the surviving ticket on a merge, de-duplicated by the given column
'ticket_forms'        => 'form_id',      // GH #272: forms the work needs, once each (the survivor's own wins)

Unmerging moves them back, as for every table in the list.

// api/tickets/permanently_delete_ticket.php
// 6a. Forms the ticket needed (GH #272) - their submissions stay: they are records of work done.
try { $conn->prepare("DELETE FROM ticket_forms WHERE ticket_id = ?")->execute([$ticketId]); } catch (PDOException $e) { if ((string)$e->getCode() !== '42S02') throw $e; }

empty_trash.php adds DELETE FROM ticket_forms WHERE ticket_id IN ($place), and debug tool D002 has the same row. The 42S02 tolerance (table not found) keeps deletes working before Database Verify.

// includes/analyst_lifecycle.php - analystReferenceRegistry()
['ticket_forms', 'added_by_id', 'history'],

history means a leaver's id is kept as a record of who added the form, not reassigned. tests/analyst-lifecycle.php fails for any *_by_id column that isn't registered.

Next: page 2 - the endpoints, the screens, the REST API and the tests.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally