Surviving a Core Upgrade
What the upgrade run does step by step, what it can take back, and the checks to run before and after.
Overview
An upgrade is a sequence of steps with a time budget, plus a ledger of everything it overwrote. A health gate sits between the last write and the version stamp.
Prerequisites
- A second installation you can break. Not the system that serves customers, and not a copy sharing its database.
- A real backup taken before the run, and verified.
- The current version from
coremio/VERSION, written down before you start. - Working Without Touching the Core: every check below assumes your code sits behind a seam.
Structure
The Run
One version is five steps in a fixed order. A chained upgrade applies each fully and in order, by the code of the version before it.
| Step | What it does | Can it be taken back? |
|---|---|---|
backup | Optional. Database, files and uploads, before anything is touched | Not applicable, it only reads |
download | Fetches the package into the work directory | Yes, nothing outside the work directory changed |
extract | Unpacks it, a batch of entries per slice | Yes, same reason |
database | Runs the release's schema statements once. Already-applied statements are skipped, out-of-order ones are replayed at the end | No. Recorded as forward-only and named to the operator |
configuration | Runs the release's configuration script, then merges language files and notification templates | Merges yes, script no: arbitrary code cannot be pre-imaged |
apply | Copies the package's files over the system, then the delete manifest, health gate and version stamp | Yes, from the ledger, once |
finish | Sweeps the work directory | Not applicable |
Each step runs until its time budget expires, writes its position into a cursor, and returns. A web request gets sixty percent of the configured execution limit, clamped between 5 and 45 seconds. The command-line slice the minutely cron uses gets 300 seconds. The cron handler stops starting new slices after 1440 seconds of its own 1800 second ceiling.
The Pre-Image Ledger
One directory per run under coremio/storage/updates-preimage, outside the work directory so it outlives the run. Every overwrite and manifest deletion is copied here first, and the copy kept is the pre-run state.
| Entry | What is in it | What reads it |
|---|---|---|
meta.json | Source version, versions applied, whether it was a re-apply, the state before | The rollback button, deciding whether it can be offered |
files/ | The pre-run copy of every file overwritten or deleted | The restore, which copies all of it back |
added.list | Paths the run created that did not exist before | The restore, which deletes them (undoing an addition is a removal) |
changed.list | Files whose contents actually changed, by checksum | The health gate, which lints exactly this list and nothing else |
forward.list | What cannot be undone: the schema migration, the release configuration script | The rollback summary, which names them to the operator |
baseline.json, gate.json, restored.json | Health before, health after, evidence of a restore | You, when you need to know what the gate saw |
The Health Gate
Health is measured twice: a baseline before the first byte of the apply step, then the gate. The verdict is relative, so a broken baseline rolls nothing back.
Three questions are asked, cheapest first. Do the changed PHP files still parse? Does a fresh process boot and reach the database? Does the system answer its own home page?
| Field | Meaning | Effect on the gate |
|---|---|---|
errors | A check ran and failed | Triggers the rollback, and the run fails as a health check failure |
skipped | A check could not run at all | No verdict. Recorded in the evidence file and otherwise ignored |
http | A state, not a boolean: ok, unreachable, or the failure | Compared against the baseline, and asked twice before it is believed |
Walkthrough
Before
- Write your dependency surface as a file, not as a memory. Every hook name you listen to. Every class and method called from outside your directory. Every core table, column and configuration key you read.
- Grep your own tree for core paths and confirm you edited none. The ledger restores the previous release, not your version.
- Check that your module carries a
manifest.jsonif it is meant to be updatable. Without one the updater never touches the directory. - Run the whole upgrade on the copy first, from the panel and not only from cron.
After
- Run your compatibility probe before you open a page. Does every class and method you call still exist?
- Exercise each hook you depend on and confirm your canary recorded a hit. There is no runtime registry of hook names.
- Read the error log. An exception inside a listener is caught, logged and swallowed, so a broken listener looks like a hook that never fired.
- Check the configuration keys your code reads. The apply step skips an existing configuration file, and the module updater preserves your
config.php. New keys are never created for you. - Re-run your own schema guard if your module owns tables or added columns. The core upgrade knows nothing about them.
- Tell the operator the rollback window is one run, once.
When a Seam Moved
| Symptom | Check | Usually means |
|---|---|---|
| Your listener no longer runs and nothing is logged | Search the hook catalogue for the name, then for a near neighbour of it | The hook was renamed, or its call site was removed. Your registration is still perfectly valid and attached to nothing |
| Your listener runs but the change is ignored | Whether the point is fired by value or by reference | It became by-reference. Your return value is discarded, and the caller wants the argument modified in place |
| Fatal: call to an undefined method | The class in the source, not the log | The name held, the method did not. Guard the call and degrade instead of throwing |
| Too few or too many arguments | The parameters table on the hook's page | The call site's argument list changed. Declare only the parameters you use and give the rest defaults |
| Your admin page returns not found | The module type, before the routes | A silently rejected admin area registration. Or a slug that now collides with a core route |
| It works from cron and dies from the panel | function_exists('exec') in both contexts | Not an upgrade regression. Shared hosting disables the process functions in the web pool; the command-line binary keeps them |
Shipping Your Own Update
- Put a
manifest.jsonin the module or theme directory. - Keep the version there and nowhere else. The one inside
config.phpfreezes at first install, because an update never writes that file. - Never reuse or edit a version number after publishing it. It is the identity every installation compares against.
- Two things survive on the customer's disk that you did not send. Their
config.php, skipped wherever one exists. And any file you removed from the package: an update overwrites but never deletes. - Write the manifest last in your own tooling too. A half-finished apply must never claim a version that is not fully on disk.
Reference
The Upgrade API
const VERSION_STEPS = ['download', 'extract', 'database', 'configuration', 'apply'];
const PREIMAGE_DIR = 'updates-preimage'; // under coremio/storage/
const PREIMAGE_KEEP = 3; // runs retained, pruned when a new run opens
const LINT_SECONDS = 60.0; // gate lint budget, time not file count
const LINT_MAX_FILES = 2000; // safety brake behind the time budget
const HTTP_SETTLE_SECONDS = 5; // waited before asking, and after restoring
public static function open(array $opts, string $workerId): int;
public static function tick(int $runId, string $workerId): array;
public static function health_probe(): array;
public static function restorable(): array;
public static function restore_last(): array;
public static function parked(array $run): bool;
public static function check_state(): array;
public static function check_new_version(): array;
public static function next_versions(string|int $ver = 0): array;
public static function run_active(string $kind = 'core'): array;
public static function last_completed_run(string $kind = 'core'): array;
public static function run_get(int $id): array;
public static function sc_state(): array;
// coremio/helpers/license.php - what the installation currently claims to be.
public static function version($realtime = false): string;
['ok' => bool, 'errors' => string[], 'skipped' => string[], 'http' => string]. Use it as the shape your own probe copies.
version_compare(): as text, a two-digit minor sorts below a one-digit one.
The Update Manifest
It sits next to the module class, or next to the theme definition rather than replacing it.
{"type":"marketplace","name":"AcmeBilling","version":"1.2.0","last_updated":"2026-07-28"}
| Field | Required | What it does |
|---|---|---|
type | Always | Which publisher answers for this directory. An unknown value makes the whole manifest invalid rather than half read |
name | Store products | The product key. The directory name is not used; it is yours to choose |
id | Marketplace listings | The listing identity, for the same reason: two developers can pick the same directory name |
version | Always | The installed version. This and only this drives the comparison |
last_updated | Optional | Shown to the operator. It takes part in no decision |
The daily task only looks for new versions and notifies the operator. No scheduled task installs an update.
Example
A compatibility probe your module can ship.
namespace WISECP\Modules\Addons\Acme\Src;
class Compat
{
// The dependency surface, as data. Everything this module touches outside its
// own directory is listed here, so the probe is the list and not a rewrite.
private const NEEDS_METHOD = [
'Checkout::payment_methods',
'Services::get',
'Invoices::create',
];
private const NEEDS_HOOK = [
'filter:order.cart_totals',
'action:order.checkout_completed',
];
private const CORE_MIN = '5.0.0';
private const CORE_MAX = '6.0.0'; // exclusive: a major bump is opt-in, never assumed
/** @return array{ok:bool,errors:string[],skipped:string[],version:string} */
public static function check(): array
{
$errors = $skipped = [];
$core = \License::version();
if (version_compare($core, self::CORE_MIN, '<'))
$errors[] = 'core ' . $core . ' is below the minimum ' . self::CORE_MIN;
if (version_compare($core, self::CORE_MAX, '>='))
$skipped[] = 'core ' . $core . ' is newer than this module was tested against';
foreach (self::NEEDS_METHOD as $ref) {
[$class, $method] = explode('::', $ref, 2);
if (!class_exists($class)) $errors[] = 'missing class: ' . $class;
elseif (!method_exists($class, $method)) $errors[] = 'missing method: ' . $ref;
}
// A capability, not a result. Under FPM these are usually disabled while the
// same server's CLI keeps them, so "cannot ask" must never read as "failed".
if (!function_exists('exec')) $skipped[] = 'subprocess probes (exec unavailable)';
foreach (self::NEEDS_HOOK as $hook)
if (!self::hook_seen($hook)) $skipped[] = 'hook not observed yet: ' . $hook;
return ['ok' => !$errors, 'errors' => $errors, 'skipped' => $skipped, 'version' => $core];
}
/*
* There is no runtime registry of hook NAMES: hooks are fired, not declared, and the
* store is private. So the only honest answer is observational - the canary below
* records a timestamp when the point actually fires, and "not seen" is skipped,
* never an error, because it may simply not have been reached yet.
*/
public static function canary(string $hook): void
{
$seen = (array) \Utility::jdecode((string) \Config::getd('acme_hook_seen'), true);
$seen[$hook] = time();
\Config::setd('acme_hook_seen', \Utility::jencode($seen));
}
private static function hook_seen(string $hook): bool
{
$seen = (array) \Utility::jdecode((string) \Config::getd('acme_hook_seen'), true);
return (int) ($seen[$hook] ?? 0) > 0;
}
}
The canary is registered next to the listener it watches, at a priority that puts it first.
use WISECP\Modules\Addons\Acme\Src\Compat;
include_once __DIR__ . DS . 'src' . DS . 'Compat.php';
// Priority 1: ahead of the real listener, so the observation is independent of it.
Hook::add('filter:order.cart_totals', 1, function (&$summary, $items, $subtotal) {
Compat::canary('filter:order.cart_totals');
});
// The gate the module itself sits behind. An incompatible core disables the feature
// instead of throwing inside somebody's checkout.
$acme_compat = Compat::check();
if ($acme_compat['ok']) {
Hook::add('filter:order.cart_totals', 20, function (&$summary, $items, $subtotal) {
// ... the real work
});
}
else {
Hook::add('ui:admin.body.end', 90, function () use ($acme_compat) {
return '<!-- acme disabled: ' . htmlspecialchars(implode('; ', $acme_compat['errors'])) . ' -->';
});
}
Two commands worth running by hand on the copy, right after the run.
# What the gate actually saw, including everything it could not measure.
cat coremio/storage/updates-preimage/*/gate.json
# Errors since the run, where a swallowed listener exception is the only trace.
php coremio/errlog.php list --limit=20
Pitfalls
Shared hosting pools disable the process functions in the web configuration, while the same server's command-line binary keeps them. A disabled function raises an error, not a warning, so the silencing operator does not help. Check the capability before you spawn a subprocess. A missing one is skipped, not failed.
Restoring the ledger puts every overwritten file back and deletes everything the run created. But the migration already moved the schema, and the configuration script already ran. If your module reads a core column, check the column rather than the version number.
The file tree can be a mixture of two versions for minutes. Scheduled work started in that window can load one class from the old release and another from the new.
The button appears only for the most recent completed run, and only while nothing is running. It disappears once used. Anything older needs a full backup.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.