Principles of Upgrade-Safe Work
Six rules that decide whether your code still runs after the next core upgrade. Each one is anchored to what the upgrade run does to a file.
Overview
Upgrade-safe is a measurable property, not a style. The upgrade run copies the release's files over this installation, deletes the paths the release names, and leaves everything else alone. Which bucket your work lands in decides whether it survives. You pick it when you decide where the code goes.
The run is inspectable. Its steps are a constant in the runner, and every overwrite is copied aside before it happens. The two things that cannot be undone are written down by name.
The property has a second direction: your code has to keep working against a core that moved. A hook you listen to can be renamed. A method you call can change its signature, and a template you print into can be rewritten. The last rule below is about proving those assumptions still hold.
Prerequisites
- Shell access, or at least the ability to read
coremio/VERSIONand the admin update screen. - A rough map of the extension points:
hooks/INDEX.md,coremio/modules,templates/website. - How Hooks Work, if the word hook does not yet mean something specific to you.
Structure
What an Upgrade Actually Touches
The apply step builds one list from the package's own file tree and copies each entry over the installation. Nothing else is visited, and deletions come from a separate explicit manifest.
| Where the file is | What the run does to it | What that means for you |
|---|---|---|
| A core file the release ships (class, helper, controller, admin template) | Overwritten byte for byte, after the pre-run copy is kept | Your edits are gone. The kept copy restores the previous release, not your edit |
| A path named in the release's delete manifest | Deleted, after the pre-run copy is kept | A rename ships as an add plus a delete, so a file you attached code to can vanish |
coremio/configuration, file already present | Skipped | Your edits survive, and so do stale defaults: keys the new release adds there never arrive |
coremio/locale | Merged key by key, incoming wins on a collision | A key you added survives. A core string you rewrote returns the moment the release ships that key |
coremio/storage | Never removed: the delete manifest refuses those paths | Runtime state is yours to keep |
| A path the package neither ships nor names | Untouched, not even copied aside | Your module, your hook file and your theme all live here |
The Six Rules
coremio/hooks and every hooks.php inside a module directory is included automatically at boot. Attaching behaviour costs a new file, never a changed one.
config.php, or the database-backed settings pair. Never a core configuration file: the run skips it, so keys a later release adds there never reach you.
The Classes of Seam
Each of these has its own article. What matters here is the shape: five kinds of extension point, each with a different cost and a different limit.
Reference
The Primitives Every Safe Change Goes Through
// coremio/classes/Hook.php - attaching to a published point.
public static function add($name, $priority, $properties = []): void;
public static function run($name, ...$args): array;
public static function runRefs($name, &...$args): array;
// coremio/classes/Config.php - settings that live in the database, not in a file.
public static function getd($name = '');
public static function setd($name = '', $content = '');
// coremio/classes/Config.php - the FILE-backed side. Read freely, write only your own file.
public static function get($arg = NULL);
public static function save($name = '', $data = []): bool;
// coremio/classes/Modules.php - the canonical way to obtain a module instance.
public static function getInstance(string $type, string $name, array $params = []): ?object;
// coremio/helpers/license.php - the running core version, read from coremio/VERSION.
public static function version($realtime = false): string;
configurations table. Returns the stored value, or an empty result when the name was never set. No file is involved.
coremio/configuration, lazy-loaded on first touch. Config::get("general/cache") reads general.php and then the cache key. A missing key returns false, not null.
coremio/VERSION and memoises it for the request. Pass true to bypass the memo, which only matters inside an upgrade run. Compare it with version_compare(), never as a string.
new yourself skips all five.
Where a Registration File Is Allowed to Live
Two globs, evaluated once per request, decide what gets included. Anything outside them has to be reached from inside them.
| Pattern | Loaded when | Use it for |
|---|---|---|
coremio/hooks/*.php | First time a hook is fired, once per request. Registering one does not trigger the pass | Behaviour that belongs to this one deployment |
coremio/modules/{Type}/{Name}/hooks.php | Same pass, same include loop | Behaviour that travels with a module |
coremio/modules/{Type}/{Name}/router.php | Admin routing, when the module registers an admin area | Module pages inside the admin panel |
coremio/cronjobs/*.php | Boot, by class scan: a class with a TYPE constant is registered | Core scheduled tasks. A module registers its own from hooks.php |
A file dropped into the hooks directory is invisible to the package system. Nothing updates it, versions it or removes it when your work is retired. Behaviour meant to ship to more than one installation belongs in a module directory.
Example
The same requirement twice: a surcharge on one payment method, once by editing the core and once through a seam.
// Inside a core helper, three lines added by hand to a method the release owns.
// The apply step ships this file, so the copy loop overwrites it and the pre-image
// ledger restores the PREVIOUS RELEASE, never the edit. There is no trace left of
// what was changed or why.
if ($pmethod === 'wire-transfer')
$summary['total'] = (float) $summary['total'] + 2.50;
// A new file in a directory no release names. Nothing overwrites it, nothing deletes it.
// Rule 3: the listener binds to a hook NAME and to documented parameter names.
// Rule 4: the amount is a database-backed setting, not a constant in a core file.
Hook::add('filter:order.cart_totals', 20, function (&$summary, $items, $subtotal, $coupons, $ctx) {
$fee = (float) Config::getd('acme_wire_fee');
if ($fee <= 0) return;
$summary['total'] = (float) ($summary['total'] ?? 0) + $fee;
$summary['tax']['total'] = $summary['total'];
});
// Rule 6: a capability question, answered before the work is attempted, and reported
// as "not measured" rather than as a failure.
Hook::add('action:cron.day.run', 50, function () {
if (!function_exists('exec')) {
// Logger::log() wants a level first; the level shorthands take the message alone.
Logger::info('acme: archive check skipped, exec() unavailable');
return;
}
// ... the probe that needs a subprocess
});
And the guard that keeps the same file quiet on a core it was not written for.
// version_compare, never a string comparison: "2.10.0" sorts BELOW "2.4.0" as text.
if (version_compare(License::version(), '5.0.0', '<')) return;
// The other half of the same guard: the method you are about to call may not be there.
// method_exists costs nothing and turns a fatal error into a disabled feature.
if (!method_exists('Checkout', 'payment_methods')) return;
Pitfalls
The apply loop skips a configuration file that already exists, so your edit survives. So do the defaults from the version you first installed. Keys the release adds there are never delivered, and the feature that reads them behaves as if the operator turned it off.
Language files are merged rather than replaced, and the incoming release wins on any key it ships. A string you added survives; a core string you rewrote comes back the first time that key is touched upstream. Add your own key and point your own code at it.
The release carries an explicit list of paths to remove, which is how a rename is expressed. If your code lives inside a core file, an upgrade that renames that file removes your work without overwriting anything. The class you extended stops existing at the same moment.
The pre-image ledger keeps three runs, and it only ever contained the files the release touched. It is a way back from one bad upgrade, not an archive of your customisations. It does not replace a backup taken before the run.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.