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.
action:module.updated; older cores call nothing of yours, so the same checks also run on a path of your own.
Structure
Detection, decision, installation and the announcement that follows are separate stages.
| 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 |
| Announcement | the finish step | action:module.updated reaches your listener with the previous and the new version (5.0-beta.4 and later) |
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. After a successful apply the installation writes the new version into its manifest. It also moves meta.version in config.php on to the same release (5.0-beta.2 and later). The rest of that file stays as the operator saved it.
// 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 and does not call the enable path again. From 5.0-beta.4 it announces the finished update with action:module.updated, and that is where the migration belongs. A customer still on an older core never receives that call, so the same checks also run from a page of yours.
public function enable(): bool
{
// The fresh install path.
$this->check_database();
return true;
}
public function adminArea(): array
{
// Cores older than 5.0-beta.4 send no update hook: enable() ran long 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.
*/
public 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.
Hook::add('action:module.updated', 10, function (array $update) {
if ($update['type'] !== 'Addons' || $update['key'] !== 'AuroraBackup') return;
// This request loaded the new release's files, so the new checks run here.
$module = Modules::getInstance('Addons', 'AuroraBackup');
$module->check_database();
// Work that depends on the release left behind is keyed to from.
if (version_compare($update['from'], '1.2.0', '<')) $module->link_old_jobs();
});
Every step of the wizard is its own request. The finish request registers the listener from the files apply put in place. A listener that throws does not undo the update: the exception goes to the error log and the wizard still reports success.
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.
// apply then moves meta.version in config.php on; finish fires action:module.updated.
ident.
release, product, developer, changelog, available.
What apply does
meta.version is moved on to the new release; a new component's own config still installs.
The finish step then calls action:module.updated and removes the job folder.
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; where one exists only its meta.version moves on
├─ hooks.php # registers the action:module.updated listener
├─ 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
The manifest decides what is offered, and an update moves the number in config.php on to the new release. A migration that compares it with the version in your code never sees the gap; key it to the hook's from.
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. Run it from the update hook and from a path that runs anyway.
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
Vielen Dank für Ihre Rückmeldung!
Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.