-
Notifications
You must be signed in to change notification settings - Fork 15
Database Integrity
How FreeITSM keeps its ~200 table relationships honest: where foreign keys are defined, why an install's history determines which ones it actually has, the class of bug that causes, and the conventions that prevent it. Written for both audiences:
- Admins: the short version is run System β Database Verification after every update. It's non-destructive and it backfills missing constraints. The rest of this page explains what it's protecting you from.
- Developers/contributors: the checklist at the bottom is mandatory for any schema change.
FreeITSM's schema lives in two places that serve different install paths:
| Source | Used by | What it defines |
|---|---|---|
database/freeitsm.sql |
Fresh installs (SQL import) | The complete DDL: tables, columns, unique keys, indexes, and all ~205 foreign-key constraints with their delete rules |
api/system/db_verify.php |
Existing ("grown") installs, via System β Database Verification | A $schema array that creates columns + primary key only, plus separate sections that backfill unique indexes ($uniqueIndexes) and foreign keys (per-feature FK groups) |
That split is deliberate β ALTER TABLE ADD CONSTRAINT on a table with existing data needs different handling than CREATE TABLE β but it creates a load-bearing asymmetry:
Important
db_verify's $schema never creates a foreign key β not even for a brand-new table. A table only gets its FKs on a grown install if someone also added them to one of db_verify's FK groups. Forget that step and you get two silently different databases: a fresh install with referential integrity, and a grown install without it.
So every install is one of two species:
- π± Fresh (imported
freeitsm.sql): all constraints present from day one. - π³ Grown (started earlier, evolved via Database Verification): has exactly the constraints that were explicitly backfilled β historically, not all of them.
When a delete endpoint says "children cascade via FK" but the FK doesn't exist on a grown install, nothing errors β MySQL just deletes the parent row and leaves the children behind. The orphans are invisible until something trips over them (a count that's wrong, a cleanup query that no-ops, a report that joins to nothing).
Three real instances were found and fixed during the REST API rollout (July 2026), because the API's test suite deliberately exercises delete-with-children on a grown database:
| Fix | Module | What was happening |
|---|---|---|
| #685 | Knowledge | Hard-deleting an article orphaned its tag links (no FKs on a grown install), which made the "clean up orphaned tags" step a permanent no-op. Bonus: the version-history FK has no cascade by design, so on a fresh install hard-deleting an article with "Save as new version" snapshots failed outright with a 1451 FK error. |
| #687 | Tasks | db_verify had only ever backfilled 4 of the 12 task FKs β no parent-subtask cascade, no comments cascade. Deleting a task silently orphaned its subtasks and comments. |
| #689 | CMDB | The worst one: db_verify had zero of the CMDB's 15 FKs, while the module's whole delete design leans on cascades (the endpoint's comments literally document them). A real historic orphan was found in the wild β a ticketβobject link whose object had been deleted months earlier. |
Two lessons became standing conventions (below): backfill FKs deliberately, and never let an endpoint rely on cascades alone.
db_verify backfills ~95 constraints across named, idempotent FK groups (each checks information_schema before attempting, and swallows failures so one bad FK never breaks verification):
| Group | Covers |
|---|---|
| Tenancy / SLA / CSAT blocks |
tenants, tenant_domains, analyst_tenant_access, SLA calendar/notification tables, CSAT responses |
$ticketChildFks |
email_attachments, ticket_notes, ticket_audit, ticket_time_entries β orphan-aware (see below) |
$changeFks |
Change Management lookups + child tables |
$problemFks (+ indexes) |
Problem statuses/priorities/tickets/audit/notes |
$ssoFks |
Analyst + requester SSO identity tables, provider ownership |
$knowledgeFks (added #685)
|
All 7 knowledge FKs β articlesβanalysts, versions, tag links |
| Tasks group (completed #687) | All 12 task FKs β was 4 (status/priority/tag-map), now includes parent CASCADE, comments CASCADE, analyst/team/ticket/change SET NULL |
$cmdbFks (added #689)
|
All 15 CMDB FKs β classes, properties, options, objects (parent CASCADE), values, relationships, ticket links |
$apiKeyFks |
REST API api_keys / api_key_rate_limits
|
| Assets / service-status / LMS / RFP sections | Their respective child tables |
Unique indexes are backfilled from the $uniqueIndexes array in the same file (with pre-dedup steps where historic duplicates could block the index).
Note
If your install predates July 2026, run Database Verification once after updating β it will attach the knowledge, task and CMDB constraint sets that older grown installs are missing.
ALTER TABLE β¦ ADD CONSTRAINT refuses (error 1452) if the table already contains rows that violate the new FK β i.e. historic orphans block the very constraint that would have prevented them. Database Verification handles this two ways:
-
Ticket child tables get the deluxe treatment: the result row explains the orphans in plain English and offers a one-click Fix button that deletes only provably-orphaned rows (parent gone) via a whitelisted endpoint, then re-runs so the FK attaches cleanly. Strictly non-destructive otherwise β db_verify never deletes data on its own. The endpoint is
api/system/fix_orphans.php, which also removes the orphaned attachment files from disk, not just the rows. - Everything else simply skips the FK (try/catch) and will attach it on a future run once the orphans are gone. The explicit-delete convention (next section) stops new orphans accumulating, so these self-heal over time once the data is cleaned.
Other constraints worth knowing:
- Everything is InnoDB + utf8mb4 β FKs require matching engine/charset and exactly-matching column types on both sides.
-
SET NULLrules require the child column to be nullable β aNOT NULLcolumn can't take aSET NULLrule (that's why e.g.ticket_audit.analyst_idhas no SET NULL: it'sNOT NULLby design, which is also why every API key acts as an analyst). -
Self-referencing cascades work (
tasks.parent_task_id,cmdb_objects.parent_idboth cascade whole trees) but MySQL's cascade depth is limited (15) β one reason deep trees are also deleted explicitly in code.
The delete rule encodes an ownership statement. FreeITSM uses them consistently:
| Rule | Meaning | Examples |
|---|---|---|
ON DELETE CASCADE |
Real ownership β the child has no life without the parent | Subtasks (tasks.parent_task_id), CMDB descendant trees + property values + relationship edges, problem tickets/notes/audit, change children, tag junction tables, api_key_rate_limits
|
ON DELETE SET NULL |
A reference that should outlive the target β losing the target shouldn't destroy the row |
tasks.ticket_id/change_id/contract_id, assets.location_id, cmdb_object_properties.value_object_id, apikeys.analyst_id, ticket-link created_by_analyst_id
|
| No action + app guard | Deletion must be a human decision β the endpoint refuses while rows reference it | Ticket/task/problem/change statuses & priorities ("Reassign them first, or set it inactive"), CMDB classes with objects, relationship types in use |
| Deliberately absent | Ticket children (ticket_notes, ticket_audit, β¦) have FKs but no cascade β delete_ticket.php/permanently_delete_ticket.php remove children explicitly so a ticket delete is always an auditable, deliberate sequence |
Soft-delete flags (is_active, is_archived, deleted_datetime) sit above all of this β most user-facing "deletes" are soft, and the FK rules only come into play at true purge time (trash empty, recycle-bin purge, permanent delete).
After #685/#687/#689, the standing rule for every delete endpoint β UI and REST API alike β is:
Never rely on a cascade you didn't verify exists. Delete children explicitly, children-first, in a transaction.
Why explicit deletes even where FKs do exist:
- They work on every install β fresh, grown, or half-migrated.
- They're self-documenting β the endpoint shows exactly what a delete takes with it.
- They stop new orphans forming even where a historic orphan is still blocking the FK from attaching.
- FK cascades remain in place as the second line of defence (and for anything that bypasses the endpoints).
Endpoints already converted: knowledge hard-delete + retention purge, task delete (walks the subtask tree), CMDB object delete (walks descendants, nulls object_ref back-references, removes properties/relationships/ticket links), and all their REST API v1 equivalents. Ticket deletion always worked this way.
Read-only queries to check an install's health β all use the LEFT JOIN β¦ IS NULL pattern:
-- Ticket children whose ticket is gone
SELECT COUNT(*) FROM ticket_notes n LEFT JOIN tickets t ON t.id = n.ticket_id WHERE t.id IS NULL;
SELECT COUNT(*) FROM ticket_audit a LEFT JOIN tickets t ON t.id = a.ticket_id WHERE t.id IS NULL;
SELECT COUNT(*) FROM email_attachments ea LEFT JOIN emails e ON e.id = ea.email_id WHERE e.id IS NULL;
-- Tasks: orphaned subtasks, comments, tag links
SELECT COUNT(*) FROM tasks s LEFT JOIN tasks p ON p.id = s.parent_task_id
WHERE s.parent_task_id IS NOT NULL AND p.id IS NULL;
SELECT COUNT(*) FROM task_comments c LEFT JOIN tasks t ON t.id = c.task_id WHERE t.id IS NULL;
SELECT COUNT(*) FROM task_tag_map m LEFT JOIN tasks t ON t.id = m.task_id WHERE t.id IS NULL;
-- Knowledge: orphaned tag links and version snapshots
SELECT COUNT(*) FROM knowledge_article_tags at
LEFT JOIN knowledge_articles a ON a.id = at.article_id WHERE a.id IS NULL;
SELECT COUNT(*) FROM knowledge_article_versions v
LEFT JOIN knowledge_articles a ON a.id = v.article_id WHERE a.id IS NULL;
-- CMDB: orphaned property values, relationship edges, ticket links
SELECT COUNT(*) FROM cmdb_object_properties op
LEFT JOIN cmdb_objects o ON o.id = op.object_id WHERE o.id IS NULL;
SELECT COUNT(*) FROM cmdb_object_relationships r
LEFT JOIN cmdb_objects f ON f.id = r.from_object_id
LEFT JOIN cmdb_objects t ON t.id = r.to_object_id
WHERE f.id IS NULL OR t.id IS NULL;
SELECT COUNT(*) FROM ticket_cmdb_objects l
LEFT JOIN cmdb_objects o ON o.id = l.cmdb_object_id WHERE o.id IS NULL;
-- Which FKs does THIS install actually have for a table?
SELECT CONSTRAINT_NAME, DELETE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks';If a count is non-zero: clean the orphans (the matching DELETE x FROM x LEFT JOIN parent β¦ WHERE parent.id IS NULL is safe β it only removes provably-parentless rows), then run Database Verification so the FK attaches and it can't recur.
Every schema change touches both sources, in full:
-
database/freeitsm.sqlβ the completeCREATE TABLEwith unique keys, indexes, and every FK with an explicit, chosen delete rule (see the conventions table above β pick the rule that states the ownership truth). -
db_verify.php$schemaβ the column map (columns + PK only; firstAUTO_INCREMENTcolumn becomes the PK). -
db_verify.php$uniqueIndexesβ one row per unique key (add a pre-dedup step if historic data could contain duplicates). -
db_verify.phpFK group β every FK from step 1, names and delete rules matchingfreeitsm.sqlexactly (grep the constraint names across both files to verify). - Endpoints β deletes remove children explicitly, children-first, in a transaction; don't lean on step 1's cascades.
- Test on a grown database β create parent + children, delete the parent, then run the orphan queries above. This exact test caught all three historic bugs.
Tip
Quick self-audit for any module: grep -c "CONSTRAINT" database/freeitsm.sql scoped to its tables vs the module's FK group in db_verify.php. If the counts don't reconcile, a grown install is missing constraints.
See also: Architecture (database conventions) Β· System (Database Verification) Β· Multi-Tenancy-Isolation (query-layer isolation, a different integrity axis) Β· REST API β How It Works (why API deletes are explicit).
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
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
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- 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: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- 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
- β³ 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)