Principles of Upgrade-Safe Work

5 views Markdown

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/VERSION and 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 isWhat the run does to itWhat 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 keptYour edits are gone. The kept copy restores the previous release, not your edit
A path named in the release's delete manifestDeleted, after the pre-run copy is keptA rename ships as an add plus a delete, so a file you attached code to can vanish
coremio/configuration, file already presentSkippedYour edits survive, and so do stale defaults: keys the new release adds there never arrive
coremio/localeMerged key by key, incoming wins on a collisionA key you added survives. A core string you rewrote returns the moment the release ships that key
coremio/storageNever removed: the delete manifest refuses those pathsRuntime state is yours to keep
A path the package neither ships nor namesUntouched, not even copied asideYour module, your hook file and your theme all live here

The Six Rules

1. Register, never edit Every file under 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.
2. Own a directory, do not colonise one A directory that is entirely yours (a module, a theme, one hook file) is invisible to the apply list. A single line added to a core file is not.
3. Bind to names, not to positions Hook names, POST field names, config keys and route keys are contracts; they change loudly. Line numbers, array offsets, template markup and CSS structure are not, and they change silently.
4. Keep your state in your own store Your own table, your module's 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.
5. When there is no seam, ask for one A missing extension point is a request, not a licence to patch the core. Opening a generic hook is the supported answer.
6. A check that could not run is not a verdict Report it as skipped and carry on. The upgrade machinery draws the same line: a probe that cannot execute is not evidence of a fault.

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.

Hooks 979 published points across 17 domains. The cheapest seam, and the only one that reaches inside a flow the core owns. Limited to the points that exist: a new trigger needs a core change.
Modules 16 types, each a directory of your own with a contract the core calls into. The widest seam, and the only one that can own database tables, settings and admin pages together.
Themes A directory under the website template root replaces the whole visitor-facing presentation. The admin panel has no equivalent layer; it is reached only through the published markup hooks.
Configuration and data Database-backed settings, operator-defined custom fields, your own tables. Nothing here is a file a release can overwrite.
Version guards The running version is a file the runner stamps last. Reading it lets your code decline on a core it was not written for instead of throwing.

Reference

The Primitives Every Safe Change Goes Through

signatures
// 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;
Config::getd() Reads one row from the configurations table. Returns the stored value, or an empty result when the name was never set. No file is involved.
Config::get() Slash-addressed lookup into a file under 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.
License::version() Reads 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.
Modules::getInstance() Loads the module and fills its configuration. It resolves the class name across the three naming shapes, supplies required constructor arguments by reflection and caches the instance. Writing 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.

PatternLoaded whenUse it for
coremio/hooks/*.phpFirst time a hook is fired, once per request. Registering one does not trigger the passBehaviour that belongs to this one deployment
coremio/modules/{Type}/{Name}/hooks.phpSame pass, same include loopBehaviour that travels with a module
coremio/modules/{Type}/{Name}/router.phpAdmin routing, when the module registers an admin areaModule pages inside the admin panel
coremio/cronjobs/*.phpBoot, by class scan: a class with a TYPE constant is registeredCore scheduled tasks. A module registers its own from hooks.php
A loose hook file is a deployment decision, not a distribution one

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.

the version that dies at the next upgrade
// 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;
coremio/modules/Addons/Acme/hooks.php
// 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.

declining gracefully
// 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

Editing a core configuration file is a staleness risk, not an overwrite risk

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.

Rewriting a core translation string loses the argument every time

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.

A file can be deleted, not only replaced

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 safety net is short and it is not a backup

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.