Shipping Module Updates
Get a new version onto installations that already have it: one manifest, one version number, one operator decision.
Overview
The installation asks once a day what is published, and the publisher answers with a version, a changelog and a product card. A newer answer raises a notice; the update runs only on a click.
A directory becomes updatable by carrying manifest.json; without it the copy is never touched.
{"type":"marketplace","id":42,"version":"1.2.0","last_updated":"2026-08-03"}
Prerequisites
2.10.0 is newer than 2.4.0. A published number cannot be reused.
zip_unavailable.
temp/module-update: archive, payload and rollback copy.
Structure
Detection, decision and installation are three separate things.
| Stage | Who runs it | What happens |
|---|---|---|
| Discovery | a daily scheduled task | Every manifest is collected and asked about in one call per source |
| Notice | the panel | A bell notice snapshotting version, changelog and card, with Update Now and Later |
| Wizard | the operator | Four resumable requests: download, extract, apply, finish, addressed by a token |
There is no automatic install step. The changelog you write is frozen the day it is discovered.
Deduplication is on module plus version and looks at unread notices only, so one in the bell keeps its text; a later run raises a fresh row.
Walkthrough
Ship the manifest
Put manifest.json next to the class file, or beside theme.php for a theme.
coremio/modules/Addons/AuroraBackup/AuroraBackup.php
coremio/modules/Addons/AuroraBackup/config.php
coremio/modules/Addons/AuroraBackup/manifest.json <- makes the directory updatable
templates/website/Aurora/theme.php
templates/website/Aurora/manifest.json <- beside theme.php, not instead of it
A manifest that cannot be understood counts as absent: unknown source, missing version, marketplace with no id, store with no name.
Bump the version
The manifest version is the only thing compared, not the one in config.php. The installation rewrites its copy after a successful apply.
// Both sides are normalised first: the first line only, and only the characters a
// version is made of, so a stray space or a comment cannot produce a version that
// compares equal to nothing.
public static function normalize(string $version): string;
public static function is_newer(string $remote, string $local): bool;
// is_newer('2.10.0', '2.4.0') === true version_compare, not string ordering
// is_newer('1.2.0', '1.2.0') === false equal is not newer
// is_newer('1.2.0', '') === false an unreadable side never triggers an update
Shape the archive
The archive is unpacked and searched; the first matching layout wins.
- A folder named exactly like the module key at the archive root.
- The full tree,
coremio/modules/{Type}/{Key}ortemplates/website/{Key}. - A single unnamed directory at the root, or the root itself.
Looking right means carrying {Key}.php or theme.php; otherwise the step stops with package_mismatch.
Migrate your own tables
The update copies files: no script of yours runs and the enable path is not called again.
public function enable(): bool
{
// The fresh install path.
$this->check_database();
return true;
}
public function adminArea(): array
{
// The upgrade path: enable() already ran, years ago, on the previous version.
$this->check_database();
// ...
return ['page_title' => 'Aurora Backup', 'content' => $this->view('index.php')];
}
/**
* Creates what is missing and adds what was added later. Safe to call on every
* request: the existence checks are the migration.
*/
private function check_database(): void
{
if (!WDB::hasTable('AuroraBackup_jobs')) {
WDB::exec('CREATE TABLE `AuroraBackup_jobs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `owner` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci');
// Created with every column already present: nothing below has to run.
return;
}
// Added in 1.2: jobs written before it could not be traced back to a schedule.
$col = WDB::query("SHOW COLUMNS FROM `AuroraBackup_jobs` LIKE 'schedule_id'");
if (!$col || !WDB::getAssoc($col))
WDB::exec('ALTER TABLE `AuroraBackup_jobs` ADD `schedule_id` INT UNSIGNED NOT NULL DEFAULT 0 AFTER `user_id`');
}
Keep creation and column repair on separate branches; a seed in the creation branch dies on an older table.
Write the changelog
The dialog groups notes under headings, so each line carries one of four types; an unknown type prints last.
[
{"type": "features", "text": "Scheduled jobs can now target a second storage account."},
{"type": "improvements", "text": "Listing a bucket with many objects no longer times out."},
{"type": "security", "text": "Restore tokens are now single use."},
{"type": "fixes", "text": "A job cancelled during upload left a partial archive behind."}
]
Reference
The manifest fields
| Field | Required | Rule |
|---|---|---|
type | always | wstore or marketplace; anything else means not managed |
id | marketplace only | The numeric listing id; directory names are not unique |
name | store only | The product key; blank rejects the whole manifest |
version | always | The installed version, and the only thing compared |
last_updated | no | Shown to the operator, never part of a decision |
The updater surface
// reading what is on disk
public static function manifest(string $dir): array;
public static function version(string $type, string $key): string;
public static function normalize(string $version): string;
public static function installed(bool $activeOnly = true): array;
public static function dir(array $item): string;
// asking the publisher
public static function releases(array $items): array;
public static function pending(bool $activeOnly = true): array;
public static function issue(string $source, string $ident): array;
public static function notes(array $changelog): array;
public static function is_newer(string $remote, string $local): bool;
// running one update
public static function token(string $source, string $ident, string $version): string;
public static function begin(string $source, string $ident, string $version): array;
public static function job(string $token): array;
public static function step(string $token, string $step): array;
public static function abandon(string $token): void;
public static function write_manifest(string $dir, array $manifest): bool;
// STEPS is the fixed order: download, extract, apply, finish.
// PRESERVED is the list of filenames apply refuses to overwrite: config.php.
ident.
release, product, developer, changelog, available.
What apply does
Failure codes
Every step throws a bare code so the panel can translate it.
| Code | Raised by | What it usually means |
|---|---|---|
not_managed | begin, apply | No readable manifest: hand installed, or malformed |
already_current | begin | Not newer; usually a reused number |
download_failed | download | The signed address returned nothing |
zip_unavailable | extract | The host has no ZipArchive |
archive_unreadable | extract | The archive would not open; often a nested one |
package_mismatch | extract, apply | No layout held the expected class file |
install_failed | apply | A copy failed; the rollback has been put back |
Example
One release, from the two files that change to what the installation reads back.
// what the customer has on disk
{"type": "marketplace", "id": 42, "version": "1.1.0", "last_updated": "2026-05-14"}
// what you put in the 1.2.0 package
{"type": "marketplace", "id": 42, "version": "1.2.0", "last_updated": "2026-08-03"}
AuroraBackup-1.2.0.zip
└─ AuroraBackup/ # the module key, exactly
├─ AuroraBackup.php # the file the extractor looks for
├─ manifest.json # version 1.2.0
├─ config.php # shipped, but skipped where one already exists
├─ lang/
│ └─ en.php # a directory, never a flat lang.php
└─ views/
├─ index.php
└─ schedules.php # new in 1.2.0, arrives as a plain copy
<?php
// temp/_manifest-probe.php : run against an unpacked copy of the package
require dirname(__DIR__) . '/bootstrap.php';
$dir = MODULE_DIR . 'Addons' . DS . 'AuroraBackup';
$manifest = ModuleUpdater::manifest($dir);
// Empty means the directory would never be offered an update at all: unknown type,
// missing version, or a marketplace record with no id.
if (!$manifest) exit("not managed: fix manifest.json\n");
$installed = (string) ($manifest['version'] ?? '');
echo 'reads as: ', $manifest['type'], ' / ', $installed, "\n";
echo ModuleUpdater::is_newer('1.2.0', $installed) ? "1.2.0 would be offered\n" : "1.2.0 would NOT be offered\n";
{
"type": "marketplace",
"id": 42,
"version": "1.2.0",
"last_updated": "2026-08-03"
}
The identity fields are carried over rather than derived again.
Pitfalls
An update never writes that file, so the number there freezes at the first install.
A root holding the class file works, and so does one holding the module folder. A version-named folder or a nested archive stops at package_mismatch.
Enabling happens once, and every upgrading customer did it long ago, so a change wired only there misses every existing install.
The old file stays on disk and stays loadable, so the replacement has to neutralise it.
A wrong number is fixed by deleting the release and republishing, never by editing it in place.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.