Issue 288 Change Failed To Open With Enforce Approvals - edmozley/freeitsm GitHub Wiki

A change failed to open with Enforce approvals on

Reported: #288 by tudang85, November 2026, with the diagnosis and a working fix · Module: Change Management · Fixed in: 5.0.0 (changelog #2766)


What you saw

  1. In Change Management → Settings → Approvals, switch the approval mode to Enforce approvals.
  2. Open an existing change.
  3. The page says Error loading change. Behind it, api/change-management/get.php returns HTTP 500, and the PHP log says:
PHP Fatal error: Uncaught Error: Class "ServiceError" not found
in includes/changes/approvals.php

With Record approvals (the default) the same change opened normally, which is why it went unnoticed: only installs that turned enforcement on hit it - and for them, no change could be opened at all.


What was actually wrong

When enforcement is on, opening a change asks which statuses it may move to next (changeApprovalAllowedStatuses()). That tries each status through changeApprovalGuardMove(), which throws ServiceError for the ones approval would block - and catches it, to leave them out of the list.

ServiceError is defined in includes/service_context.php. approvals.php never loaded that file. It worked wherever the code calling it had loaded it first - saving a change goes through the change service, which does. But the change details endpoint, get.php, loads only approvals.php. So the first throw there hit a class PHP had never heard of, and a missing class is a fatal error, not an exception anything can catch.

It was 26 files, not one

Searching for every file that throws ServiceError without loading it found 26 - across changes, projects, assets, tasks, domains, service status and money. Each worked only because every caller so far happened to load the class first. Any new caller that didn't would have had the same crash.


The fix

Every file that throws ServiceError now loads it itself:

require_once __DIR__ . '/../service_context.php';   // ServiceError, thrown below (GH #288)

service_context.php only defines classes, so loading it twice is harmless, and loading it everywhere it is thrown means no caller can be "the one that forgot".

The rule is now written where the class lives, as a TRAP: in includes/service_context.php, and enforced by a test.

No database change.


Files changed

File Change
🔴 includes/changes/approvals.php loads service_context.php - the one that crashed
🔵 25 more files in includes/ and api/ the same line, before anyone could hit it
🔵 includes/service_context.php the TRAP: on ServiceError
🧪 tests/service-error-loaded.php new: fails any file that throws ServiceError without loading it

How it was proved

  • Live, on a throwaway install: a change opened through the real get.php with change_approval_mode = enforce - HTTP 500 on the old code, HTTP 200 with the fix.
  • tests/service-error-loaded.php: 69 files throw ServiceError and all load it. With the old approvals.php back, it fails and names the file.
  • Still passing, for the modules the 26 files belong to: change-approvals (114), change-risk-policy (46), asset-merge (25), leases (33), money (52), project-plan-order (10), project-member-companies (24), task-evidence (27).

What this means for you

  • If you use Enforce approvals, upgrade to 5.0.0 - changes open again.
  • Nothing to run after upgrading beyond the usual.

Related