Packaging a Module for Distribution
Turn a module directory into an archive somebody else can install: what belongs in it and what must never be.
Overview
A package is one directory, zipped, with its path preserved. Two consumers read it differently.
| Consumer | Accepts | Result |
|---|---|---|
| Manual upload | The full tree: coremio at the root | Merged into the installation root, file by file |
| The update wizard | The module folder, the tree, or an unnamed folder | Into the module's directory, after a rollback copy |
| A store download | The module folder at the root | Into the module directory, then a manifest |
One shape satisfies all three: ship the full tree, rooted at coremio/modules/{Type}/{Name}/.
Prerequisites
Structure
Everything your module owns lives in one directory named like its class.
AuroraBackup-1.2.0.zip
└─ coremio/
└─ modules/
└─ Addons/
└─ AuroraBackup/
├─ AuroraBackup.php # the class; the loader looks for this exact name
├─ config.php # settings and meta; skipped by an update if present
├─ manifest.json # enrols the copy in the update check
├─ hooks.php # optional, loaded by the hook system
├─ router.php # optional, registers an admin page
├─ AdminArea.php # optional, the admin page itself
├─ logo.png # optional, picked up by name
├─ lang/
│ ├─ en.php # the fallback, always ship it
│ └─ tr.php
├─ views/
│ └─ index.php
└─ src/
└─ ApiClient.php # your own helper classes
Rename the directory and the class lookup, update detection and logo all stop working.
Walkthrough
Lay out the directory
Four files are read from fixed places.
{Name}.phpdeclares the class; the factory tries{Name}_Module,{Name}, then the namespaced form.config.phpreturns an array:statusswitches an addon,metacarries name, author, icon.lang/{code}.phpreturns a flat array; active language first, English as fallback.manifest.jsonmakes the installed copy updatable.
Archive shape
Zip the full tree from the installation root, only your own directory.
# From the root of a clean installation, so the stored paths are root relative.
zip -r AuroraBackup-1.2.0.zip coremio/modules/Addons/AuroraBackup \
-x '*/.git/*' '*/.DS_Store' '*/node_modules/*' '*.log' '*.map'
# Read the archive back and look at it. This is the only way to see what you
# actually shipped rather than what you meant to ship.
unzip -l AuroraBackup-1.2.0.zip
A zip inside the zip is unpacked once and matches nothing.
Strip what must not ship
Leftovers bloat the package; credentials and stray files damage the target.
Read the built archive, not the working directory.
What happens on the target system
The operator uploads from the panel; what follows is fixed.
- The upload is validated:
.zipor.tar.gz, under the size ceiling. - It is stored under
tempand unpacked into a working folder. - The
coremioroot makes the whole tree move onto the installation root. - The working folder and the archive are deleted either way.
- An addon can be switched on in the same action, which calls your
enable().
public function enable(): bool
{
// Everything the module needs in order to exist: its tables, its notification
// templates, its seed rows. Idempotent, because an operator can switch a module
// off and on again as often as they like.
$this->check_database();
// Column repair runs after table creation, never inside it: a seed that writes a
// column added later would die on an installation whose table predates it.
$this->check_columns();
// Returning false aborts the switch-on and leaves the module disabled, so use it
// for a genuine blocker and never for a warning.
return true;
}
No manifest is written, and no enable() runs for types with no switch: a server or registrar module creates what it needs elsewhere.
Verify before publishing
Unpack the built archive into an empty installation and read it back.
<?php
require dirname(__DIR__) . '/bootstrap.php';
$type = 'Addons';
$key = 'AuroraBackup';
$dir = MODULE_DIR . $type . DS . $key;
$fail = [];
// 1. The four files the platform reads from fixed places.
foreach ([$key . '.php', 'config.php', 'manifest.json', 'lang' . DS . 'en.php'] as $file)
if (!is_file($dir . DS . $file)) $fail[] = 'missing: ' . $file;
// 2. The loader's own answer, not our guess at it.
Modules::Load($type, $key, true);
$config = Modules::Config($type, $key);
if (!is_array($config)) $fail[] = 'config.php did not return an array';
// 3. Credentials that survived the build. Read the SHIPPED settings, not your notes.
foreach ((array) ($config['settings'] ?? []) as $name => $value)
if (is_string($value) && $value !== '') $fail[] = 'setting not blank: ' . $name;
// 4. Update enrolment.
if (!ModuleUpdater::manifest($dir)) $fail[] = 'manifest.json unreadable: no updates would ever be offered';
echo $fail ? implode("\n", $fail) . "\n" : "package looks shippable\n";
Reference
What the platform reads
| File | Read by | Without it |
|---|---|---|
{Name}.php | the loader; the wizard's proof | Nothing matches, nothing can be instantiated |
config.php | the loader, on every registry build | No settings, no status: loaded but inert |
lang/en.php | the loader, as the fallback | Labels resolve to nothing in untranslated languages |
lang/{code}.php | the loader, on the active language | That language falls back to English |
manifest.json | the daily update check | The copy reads as hand installed, never updated |
logo.* | the logo resolver, by glob | No logo, unless the config names one |
hooks.php | the hook system, at boot | Nothing registers |
Upload limits
.zip and .tar.gz; extraction uses ZipArchive.
What must never be in the archive
| Do not ship | Why |
|---|---|
Anything outside coremio/modules/{Type}/{Name}/ | It replaces a core file, silently |
Your API keys and test accounts in config.php | The settings array ships verbatim |
| A licence public key you have not rotated | It is what makes a forged answer worthless |
| Version control and editor directories | History, branch names, credentials still in the log |
| Dependency and build directories, source maps | A source map undoes the build |
| Development scratch files and probe scripts | A bootstrapping script becomes reachable on their server |
| Logs, dumps, backups and storage output | Somebody else's data, stale and possibly personal |
| A second archive inside the archive | Unpacking happens once, so nothing matches |
Entry points
// The archive handler both upload paths share. $name empty means "work the key out
// from the tree", which is why the manual path needs the full tree.
public function extract_archive($file = '', $name = '', $group = 'Addons'): string;
// Registry build: reads config.php, then lang/{active}.php with lang/en.php as the
// fallback, then includes {Name}.php unless the class is already declared.
public static function add($file, $type, $nominc = false, $status = '');
public static function Load($type = '', $name = '', $nominc = false, $status = '');
// getInstance takes the FIRST of these three class names that exists, in this order:
// {Name}_Module -> {Name} -> WISECP\Modules\{Ucfirst Type}\{Name}
// Missing constructor arguments are padded with null, so a required parameter your
// class cannot cope with as null is a crash at instantiation, not a helpful error.
public static function getInstance(string $type, string $name, array $params = []): ?object;
// Switching an addon on. Calls activate() then enable() and writes the flag only if
// the last one returned true.
public function change_addon_status($arg = '');
// Written by the store install path, and by you inside the package for every other route.
public static function write_manifest(string $dir, array $manifest): bool;
new.
Example
A paid addon, packaged: three files decide whether it installs and updates.
$ unzip -l AuroraBackup-1.2.0.zip
coremio/modules/Addons/AuroraBackup/AuroraBackup.php
coremio/modules/Addons/AuroraBackup/config.php
coremio/modules/Addons/AuroraBackup/manifest.json
coremio/modules/Addons/AuroraBackup/hooks.php
coremio/modules/Addons/AuroraBackup/logo.png
coremio/modules/Addons/AuroraBackup/lang/en.php
coremio/modules/Addons/AuroraBackup/lang/tr.php
coremio/modules/Addons/AuroraBackup/views/index.php
coremio/modules/Addons/AuroraBackup/views/unlicensed.php
coremio/modules/Addons/AuroraBackup/src/ApiClient.php
# Nothing above coremio/modules/Addons/AuroraBackup. No .git, no node_modules,
# no temp scripts, no second archive, no storage output.
<?php
return [
'created_at' => 1785312000,
'meta' => [
'name' => 'Aurora Backup',
'version' => '1.2.0',
'author' => 'Aurora Systems',
'opening-type' => 'normal',
'icon_type' => 'font',
'icon' => 'bi bi-cloud-arrow-up',
'slug' => 'aurora-backup',
],
'show_on_adminArea' => true,
'show_on_clientArea' => false,
// Ships disabled: the operator switches it on, and that is what calls enable().
'status' => false,
'access_ps' => [],
// Every value blank. This array is written back verbatim from your disk.
'settings' => [
'api_endpoint' => '',
'api_key' => '',
'bucket' => '',
],
];
{
"type": "marketplace",
"id": 42,
"version": "1.2.0",
"last_updated": "2026-08-03"
}
The version appears twice: config is what the operator reads, the manifest is what updates compare against.
Pitfalls
An archive rooted at the module folder installs through the wizard; the manual upload refuses it.
Nothing blanks it for you. Blank the settings as a build step and check the archive.
Nothing fails and the module works, but it is never offered an update again.
English is the fallback, so a module with only a Turkish file shows blank labels elsewhere.
Upload it as a customer would, switch it on, then update from the previous version.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.