Packaging a Module for Distribution

5 views Markdown

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.

ConsumerAcceptsResult
Manual uploadThe full tree: coremio at the rootMerged into the installation root, file by file
The update wizardThe module folder, the tree, or an unnamed folderInto the module's directory, after a rollback copy
A store downloadThe module folder at the rootInto the module directory, then a manifest

One shape satisfies all three: ship the full tree, rooted at coremio/modules/{Type}/{Name}/.

Prerequisites

A module that runs from a clean copy Install into an empty installation first; a hand-added dependency is missing elsewhere.
No absolute paths anywhere Build paths from the constants.
A neutral config file Blank every setting, then check the archive.
A version number you will not reuse The identity installations compare against; decide it before you build.
An English language file The loader falls back to it for untranslated languages.

Structure

Everything your module owns lives in one directory named like its class.

the directory and its archive
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.

  1. {Name}.php declares the class; the factory tries {Name}_Module, {Name}, then the namespaced form.
  2. config.php returns an array: status switches an addon, meta carries name, author, icon.
  3. lang/{code}.php returns a flat array; active language first, English as fallback.
  4. manifest.json makes the installed copy updatable.

Archive shape

Zip the full tree from the installation root, only your own directory.

building the archive
# 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.

  1. The upload is validated: .zip or .tar.gz, under the size ceiling.
  2. It is stored under temp and unpacked into a working folder.
  3. The coremio root makes the whole tree move onto the installation root.
  4. The working folder and the archive are deleted either way.
  5. An addon can be switched on in the same action, which calls your enable().
what enable() must do
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.

temp/_package-check.php
<?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

FileRead byWithout it
{Name}.phpthe loader; the wizard's proofNothing matches, nothing can be instantiated
config.phpthe loader, on every registry buildNo settings, no status: loaded but inert
lang/en.phpthe loader, as the fallbackLabels resolve to nothing in untranslated languages
lang/{code}.phpthe loader, on the active languageThat language falls back to English
manifest.jsonthe daily update checkThe copy reads as hand installed, never updated
logo.*the logo resolver, by globNo logo, unless the config names one
hooks.phpthe hook system, at bootNothing registers

Upload limits

Extension Only .zip and .tar.gz; extraction uses ZipArchive.
Size A ceiling of 50 MB, before the host's limits.
A coremio directory at the archive root The module key comes from the first directory under the type folder.
Existing files are overwritten Per file: same paths replaced, others left alone.
Temp is always cleaned On success and on failure, so a failed install leaves nothing.

What must never be in the archive

Do not shipWhy
Anything outside coremio/modules/{Type}/{Name}/It replaces a core file, silently
Your API keys and test accounts in config.phpThe settings array ships verbatim
A licence public key you have not rotatedIt is what makes a forged answer worthless
Version control and editor directoriesHistory, branch names, credentials still in the log
Dependency and build directories, source mapsA source map undoes the build
Development scratch files and probe scriptsA bootstrapping script becomes reachable on their server
Logs, dumps, backups and storage outputSomebody else's data, stale and possibly personal
A second archive inside the archiveUnpacking happens once, so nothing matches

Entry points

signatures
// 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;
AdminTools::extract_archive() One folder with a known key, the whole tree without one.
Modules::add() Where the four fixed filenames are read.
Modules::getInstance() The canonical way to get an instance; never new.
AddonModule::change_addon_status() The only caller of enable and disable.
ModuleUpdater::write_manifest() Store path only, so the manifest travels inside your archive.

Example

A paid addon, packaged: three files decide whether it installs and updates.

what the archive contains, in full
$ 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.
config.php as it ships
<?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'       => '',
    ],
];
manifest.json as it ships
{
    "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

Zipping the folder instead of the tree

An archive rooted at the module folder installs through the wizard; the manual upload refuses it.

Shipping your test config

Nothing blanks it for you. Blank the settings as a build step and check the archive.

A missing manifest freezes the copy

Nothing fails and the module works, but it is never offered an update again.

Shipping only your own language

English is the fallback, so a module with only a Turkish file shows blank labels elsewhere.

Install your own package first

Upload it as a customer would, switch it on, then update from the previous version.

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.