-
Notifications
You must be signed in to change notification settings - Fork 15
Time Based Triggers
"Escalate this before the SLA breaches." "Remind me 30 days before this contract ends."
These are the two automations people most want from an ITSM tool, and until #801 FreeITSM could not express either of them. This page explains why they were hard, how they work, and what you must schedule to make them run at all.
β οΈ The one thing to know first. These four triggers depend on cron jobs. If those aren't scheduled, the workflows sit there, active, and never fire β and nothing anywhere tells you why. See Setting up the crons.
Every other trigger in FreeITSM hangs off a write path. Someone saved a ticket, submitted a form, approved a change β the code that performs the write calls WorkflowEngine::dispatch() in the same request, synchronously. There was a moment to fire from.
"The SLA is about to breach" has no such moment.
Nothing happened. Time passed.
There is no save, no request, no actor β so there is nothing to hang a dispatch() off. Something has to go looking. That single fact is what makes this whole feature shaped the way it is.
| Trigger | Emitted by | Schedule |
|---|---|---|
sla.warning |
cron/sla_breach_check.php |
every 5 min |
sla.breached |
cron/sla_breach_check.php |
every 5 min |
contract.expiring |
cron/workflow_scheduled.php |
hourly |
asset.warranty_expiring |
cron/workflow_scheduled.php |
hourly |
The SLA events piggyback the cron that already exists. sla_breach_check.php already walks every open ticket and computes its SLA state, because it sends the SLA notification emails. Emitting workflow events from that same pass costs nothing extra β and, more importantly, cannot drift from what the SLA emails believe. Two independent implementations of "is this ticket breaching?" would eventually disagree, and you'd have no idea which one was right.
They are emitted before the email-rule lookup, deliberately: a workflow must fire whether or not anyone configured a notification rule. Notification rules decide who gets an email. They have nothing to say about automation.
Expiries fire at 90, 30, 7 and 1 days out. Each window is a separate emission carrying window_days in the payload β so "only remind me at 30 days" is a plain condition (window_days equals 30), not something your workflow has to schedule for itself.
Finding a breached SLA is easy. Not telling you about it 300 times is the feature.
A time-based condition stays true. A breached SLA is still breached five minutes later, and five minutes after that, and at 3am. Naive detection would re-fire the same escalation on every cron run, forever β which is worse than not having the feature, because you'd also have trained everyone to ignore the channel it shouts into.
So every emission goes through a ledger:
workflow_scheduled_emissions
UNIQUE (trigger_event, entity_key, fingerprint)
written with INSERT IGNORE
Only the insert that actually creates a row dispatches. The database is the arbiter, not the application β which means two overlapping cron runs cannot double-fire, no matter how they interleave. Getting this right in application code (check, then write) is a race; getting it right with a unique key is not.
The fingerprint is the state the emission was recorded against: an SLA's target in minutes, a contract's end date.
Change that state, and the situation is allowed to fire again:
- Raise a ticket's priority β its SLA target shrinks β new fingerprint β the new, tighter deadline is allowed to breach and escalate. Without this, the workflow would go quiet at exactly the moment the ticket became more urgent β the worst possible time to go quiet.
- Renew a contract β new end date β new fingerprint β next year's reminders still fire.
Without a fingerprint, "fire once" silently means "fire once ever, even if the thing you were watching changed underneath you."
If no active workflow is listening for an event, no ledger row is written.
This one was a real bug in the first implementation, caught in testing β and it's written up as Pitfalls #9. Recording emissions with nothing listening seems harmless and is catastrophic: the ledger would say "already fired" for every contract currently inside its window, so switching on a renewal workflow tomorrow would leave it silent for all of them. Enabling a workflow would appear to do nothing, for a reason you could never see.
Instead, the first cron run after you activate a workflow fires for everything currently in-window. Which is what anyone would expect.
Windows (Task Scheduler)
Program: C:\wamp64\bin\php\php8.4.0\php.exe
Arguments: C:\wamp64\www\freeitsm-app\cron\workflow_scheduled.php
Trigger: Daily, repeat every 1 hour, indefinitely
Do the same for cron/sla_breach_check.php at every 5 minutes if you want the SLA triggers.
Linux (crontab)
*/5 * * * * /usr/bin/php /var/www/freeitsm-app/cron/sla_breach_check.php >/dev/null 2>&1
0 * * * * /usr/bin/php /var/www/freeitsm-app/cron/workflow_scheduled.php >/dev/null 2>&1There is a 5-minute minimum interval between runs (workflow_cron_min_interval_seconds), which defeats double-scheduling and runaway loops. Running the script more often than that returns Rate limited rather than doing the work twice.
Full setup notes: docs/workflow-scheduled-cron-setup.md.
Four templates in New from template exist only because of these triggers:
| Recipe | Trigger |
|---|---|
| Escalate before the SLA breaches | sla.warning |
| Alert when an SLA is breached | sla.breached |
| Contract renewal reminder | contract.expiring |
| Asset warranty expiry reminder | asset.warranty_expiring |
The two expiry recipes ship filtered to the 30-day window (window_days equals 30). Change or drop that condition to be told at every window. This catches people: a contract 41 days out crosses the 90-day window today and won't match a 30-day filter β so the run is correctly logged as skipped, not failed.
Worth knowing, because everything on this page described the design correctly and the code did not match it.
cron/sla_breach_check.php died the moment it was invoked β sla_format_minutes() was
declared unguarded in both includes/sla.php and includes/sla_notifications.php,
and the latter requires the former. A duplicate function declaration is a PHP fatal,
so the cron never reached a single line of its own logic. sla.warning and
sla.breached had therefore never fired, and no SLA email had ever sent, since the
feature shipped on 17 May 2026 (#299).
Behind it sat two more faults in sla_emit_workflow_event(), both invisible because the
fatal came first: the payload query selected type_id and created_by from tickets
(they are ticket_type_id and user_id), and the requester lookup selected
emails.from_email (it is from_address). Each would have thrown into a catch that
logs and returns β silent.
The lesson for this page: a trigger being registered, documented and visible in the
editor proves nothing about whether it fires. When you add one, make it fire β create
a throwaway workflow, emit the event, read workflow_executions back. That is a
two-minute check and it is the only one that would have caught this.
Because nothing fires until something goes looking, this is the one feature that can't demonstrate itself. To prove it end to end:
- Build the workflow first, and activate it. Ordering matters β see audience of nobody above. Run the cron before the workflow exists and you'll simply get nothing.
- Make sure something is actually in-window (a contract with an end date inside 90 days).
-
Run the cron by hand, rather than waiting for the scheduler:
Expect:
C:\wamp64\bin\php\php8.4.0\php.exe C:\wamp64\www\freeitsm-app\cron\workflow_scheduled.phpOK β contract.expiring: 1, asset.warranty_expiring: 0, pruned 0 old emission(s). - Check the execution log. The run's trigger-payload snapshot will show
window_daysand the record it fired against. -
Run it again. It should report
0and create no second run β that's the ledger doing its job. (Inside 5 minutes you'll getRate limitedinstead; that's the interval guard, not a failure.) - Change the underlying date and run once more. It should fire again β that's the fingerprint re-arming, and it's the difference between "fire once" and "fire once ever, even if the world changed".
"Licence expiring in 30 days." "This CI hasn't been audited in a year." "A ticket has sat unassigned for 4 hours."
All of these are the same shape, and the machinery is generic β you do not need a new table, a new cron, or a new ledger. You need a detector and two well-chosen strings.
workflow/includes/engine.php β availableTriggers(). Put it in the time-based block and say so in the label, because the editor's trigger picker is the only place a user learns this needs a cron:
'licence.expiring' => 'A software licence is approaching expiry (time-based)',includes/workflow_scheduled.php. Query only what's inside the widest window, then walk the windows narrowest-first. Model it on workflowEmitContractExpiries():
function workflowEmitLicenceExpiries(PDO $conn): int
{
$fired = 0;
$windows = workflowExpiryWindows(); // [90, 30, 7, 1]
$rows = $conn->prepare(
"SELECT id, name, expiry_date,
DATEDIFF(expiry_date, CURDATE()) AS days_remaining
FROM software_licences
WHERE expiry_date IS NOT NULL
AND expiry_date >= CURDATE()
AND expiry_date <= DATE_ADD(CURDATE(), INTERVAL ? DAY)"
);
$rows->execute([max($windows)]);
foreach ($rows->fetchAll(PDO::FETCH_ASSOC) as $l) {
$days = (int)$l['days_remaining'];
foreach ($windows as $w) {
if ($days > $w) continue; // hasn't entered this window yet
$fired += workflowEmitOnce(
$conn,
'licence.expiring',
'licence:' . (int)$l['id'] . ':' . $w, // entity_key
(string)$l['expiry_date'], // fingerprint
[
'licence' => ['id' => (int)$l['id'], 'name' => $l['name'], 'end_date' => $l['expiry_date'], 'days_remaining' => $days],
'window_days' => $w,
]
) ? 1 : 0;
}
}
return $fired;
}Everything else is boilerplate. Get these two wrong and the feature is worse than useless.
| Answers | Rule of thumb | |
|---|---|---|
entity_key |
What is this about? | The thing's stable identity β plus the window, if it has windows. contract:41:30
|
fingerprint |
Against what state? | The value that, if it changed, must be allowed to fire again. A deadline, an end date, a target in minutes. |
Ask yourself: "if a user changes something so this becomes newly urgent, must it fire again?" If yes, that something is the fingerprint.
The SLA case is the one that teaches it. Fingerprint on the ticket alone and raising a P3 to a P1 β which shrinks the SLA target β would leave the workflow silent, because it already fired against the old, laxer deadline. The automation would go quiet at exactly the moment the ticket got more urgent. Fingerprint on the target, and the tighter deadline re-arms.
workflowScheduledRun() β one line. The cron picks it up; nothing else to schedule:
return [
'contract_expiring' => workflowEmitContractExpiries($conn),
'asset_warranty_expiring' => workflowEmitWarrantyExpiries($conn),
'licence_expiring' => workflowEmitLicenceExpiries($conn), // β new
];workflow/includes/templates.php. A trigger nobody can find is a trigger nobody uses β the recipe is the discovery mechanism. Follow the Starter templates conventions ($lookup for install-specific ids, $configure for user-supplied values), and if it has windows, filter to one and say so in the description.
If your event has a write path, don't put it here. A licence deleted is a write β dispatch it from the service layer, synchronously, like every other trigger. Only put it in the scheduled cron when there is genuinely no moment to fire from.
The test: did something happen, or did time merely pass?
The instinct to reuse this machinery is strongest for events that arrive on a cron but are not about time. The issue-tracker triggers (tracker.issue_status_changed, tracker.issue_comment_added) are the worked example: they are discovered by a poll, because a self-hosted install cannot be called back by Jira β but something happened. A developer moved the issue. Time did not merely pass.
They are dispatched from the point where the new state is already persisted, so they are edge-triggered by construction and need no ledger. Putting them through a fingerprint would be actively wrong: todo β in_progress β todo β in_progress is three real transitions, and a fingerprint on current state would silently swallow the third.
See External Issue Trackers β Developer Guide Β§7f.
The ledger is what makes these operations non-obvious. Three traps:
-
Changing the fingerprint formula re-arms everything. The old rows no longer match, so every record currently in-window is "new" again β the next cron run will fire for all of them at once. On a busy install that's a flood of tickets or chat messages. Expect it, and consider clearing the affected
workflow_scheduled_emissionsrows deliberately rather than being surprised. -
Changing the window list (
workflowExpiryWindows()) mints newentity_keys, so every record inside a newly added window fires on the next run. -
Renaming or deleting a trigger orphans live workflows silently.
workflows.trigger_eventstores the event as a plain string β nothing enforces that it still exists. A workflow whose trigger you deleted stays active, looks perfectly healthy in the list, and simply never fires again. If you rename an event, migrate the column:UPDATE workflows SET trigger_event = 'licence.expiring' WHERE trigger_event = 'software_licence.expiring';
No schema change is needed for a new trigger β workflow_scheduled_emissions is generic, keyed on strings you supply. That's deliberate: adding a trigger should be a code change, not a migration.
Per the project's standing rules: CHANGELOG.local.md, the in-app help (lang/en/workflow.php and lang/pt-BR/workflow.php β the i18n fallback is per-key, so a missing translation shows English mid-page and nothing warns you), docs/workflow-scheduled-cron-setup.md, and this page.
| File | Role |
|---|---|
cron/workflow_scheduled.php |
Entry point for the expiry triggers (token auth on HTTP, min-interval guard) |
includes/workflow_scheduled.php |
The detectors, the ledger, the fingerprints, the pruner |
cron/sla_breach_check.php |
Emits sla.warning / sla.breached from the existing SLA pass |
workflow/includes/engine.php |
availableTriggers() β where a new event is registered |
workflow/includes/templates.php |
The starter recipes |
workflow_scheduled_emissions |
The fire-once ledger (unique key + INSERT IGNORE) |
- Workflows β the engine, the canvas, conditions, actions and the execution log.
- Workflow & Webhook Pitfalls β including #9, the ledger bug this feature very nearly shipped with.
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
- β³ π 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)