Writing an Import Module
Move another billing platform into this one, in resumable chunks, by writing a class the migration wizard drives step by step.
Overview
An Imports module is a one-way migration: it reads someone else's database and writes this installation's tables. Three modules ship: WHMCS, Blesta and WISECP.
There is no base class and no interface. The wizard instantiates your class by name, assigns four properties, then calls methods it probes for.
The chunk loop matters most. A migration cannot finish inside one request, so the wizard calls one data type at a time and your module returns a row count and a done flag. The id map is persisted between calls.
Prerequisites
- Read access to the source database: host, database, user, password.
- The source's encryption key, if it stores secrets encrypted.
- Domain Helpers: an import writes through them, not with raw inserts.
- A restore point; the wizard offers to queue one.
Structure
coremio/modules/Imports/AcmeBill/
├── AcmeBill.php class AcmeBill in namespace WISECP\Modules\Imports
├── hooks.php optional; only if imported rows need runtime help afterwards
├── lang/
│ ├── en.php name + area-* (the platform card) + one -desc per data type
│ └── tr.php
└── pages/
└── index.php the connection form, rendered inside the wizard's platform card
All for the platform list.
pages/index.php in your platform card, called from area().
Walkthrough
1. Scaffold the Platform
- Create
coremio/modules/Imports/AcmeBill/AcmeBill.phpin theWISECP\Modules\Importsnamespace. - Declare the four properties the wizard assigns.
- Write
lang/en.phpandlang/tr.php; thearea-*keys build the platform card. - Add one
<type>-desckey per data type.
2. Connect and Declare Types
- Write
pages/index.php, inputs namedAcmeBill[db_host]. The wizard hands the group to you as one array. - Implement
connect(): validate, connect, build a token from the details. That token is half the map's storage key. - Filter each credential by kind; passwords use the pass-through filter.
- Implement
data_types(): a name, a description and arequiredlist per entry.
3. Write the Chunk Loop
- Implement
pull_data(): once per type, it only counts rows. - Implement
transfer_data(): load the map, dispatch per type, save the map either way, return the row count and a done flag. - In a type method, select rows above the highest mapped id, limited to the chunk size, and record every new id.
- Compute
doneby asking the source what is left after the last mapped id.
4. Awkward Parts
- Import prerequisites at the top of the type method: a type can be selected alone.
- Respect the mode: clean carries source ids and refuses a non-empty table; enrich adds alongside.
- Implement
disconnect(), called after every step. - For anything that cannot work yet, add a
hooks.php; Blesta rewrites unreadable passwords on first login.
Reference
What the Wizard Calls
No interface exists. These names are probed before each call.
// ── assigned onto the instance by the wizard, declare all four ──────────
public array $lang; // your lang file, already resolved to the admin's language
public string $area_link; // the wizard's own address, for links inside your page
public string $name; // your module name, e.g. 'AcmeBill'
public object $controller; // the admin controller running the request
// ── read by the transfer step when the property exists ──────────────────
public array $selected_data_types = []; // everything selected in this run
public string $import_type = 'enrich'; // 'enrich' | 'clean'
public int $records_per_request = 10; // chunk size chosen by the operator
// ── methods, each guarded by method_exists() before it is called ────────
public function area(): void; // renders your connection form
public function connect($info = []): void; // $info = the posted credential group, untyped in all three shipped modules
public function disconnect(): void; // called at the end of every step
// ['type' => ['name' => string, 'description' => string, 'required' => string[]]]
public function data_types(): array;
// ['status' => 'successful', 'count' => int, 'type' => string]
public function pull_data(string $type = ''): array;
// ['processed' => int, 'done' => bool] - one chunk, called repeatedly
public function transfer_data(string $type): array;
Step Machine
Every action posts to one operation with a step field.
| step | What the operation does | What it calls on your module |
|---|---|---|
validation | Stores the backup preference, filters the types. | connect(), data_types() |
pull_data | Asks for a row count. | connect(), pull_data($type) |
select | Validates prerequisites, remembers mode and selection. | connect() only |
backup_status | Restore point, before the module loads. | nothing |
transfer | Runs a gate hook, transfers one chunk. | connect(), transfer_data($type) |
backup_status. Make it cheap: once per chunk.
connect().
The Id Map
One structure, three jobs.
// Shape: ['users' => [sourceId => newId, ...], 'orders' => [...], 'language' => 'english']
// Stored under "import_{$this->name}_{$this->token}", where the token is a hash of the
// connection details - so two source databases never share one run's progress.
public function initialize_data($predefined_data = []): void
{
if ($this->data === null) {
$data = Config::getd("import_" . $this->name . "_" . $this->token) ?: [];
if (!$data || !is_array($data)) $data = [];
}
else $data = [];
$this->data = array_replace_recursive($predefined_data, $data);
}
public function save_data(int|array $overwrite_data = -1): void
{
$data = $this->data ?? [];
if ($overwrite_data !== -1) $data = $overwrite_data;
Config::setd("import_" . $this->name . "_" . $this->token, $data);
}
// The resume point is simply the highest source id already recorded for that type.
public function last_id($array): int
{
return $array ? max(array_keys($array)) : 0;
}
Enrich and Clean
Example
The skeleton plus one data type, then the wizard side.
<?php
namespace WISECP\Modules\Imports;
use Config;
use Database;
use Exception;
use Filter;
use Language;
use Modules;
use User;
use Utility;
use Validation;
use WDB;
class AcmeBill
{
// Assigned by the wizard. A typed property that is not declared here throws on write.
public array $lang;
public string $area_link;
public string $name;
public object $controller;
// Read by the transfer step when present.
public array $selected_data_types = [];
public string $import_type = 'enrich';
public int $records_per_request = 10;
public string $token = '';
private ?Database $db = null;
private ?array $data = null;
public function __construct()
{
@set_time_limit(0);
}
public function area(): void
{
echo Modules::getPage("Imports", $this->name, Filter::route($_GET["page"] ?? "index"), [
'module' => $this,
]);
}
public function connect($info = []): void
{
$host = Filter::html_clear($info["db_host"] ?? '') ?: 'localhost';
$user = Filter::html_clear($info["db_username"] ?? '');
$name = Filter::route($info["db_name"] ?? '');
// Pass-through on purpose: every other filter strips the characters that make
// a password strong, and the connection then fails for a reason nobody can see.
$pass = Filter::password($info["db_password"] ?? '');
if (Validation::isEmpty($user)) throw new Exception(Language::gc("admin/tools/error6"));
if (Validation::isEmpty($pass)) throw new Exception(Language::gc("admin/tools/error7"));
if (Validation::isEmpty($name)) throw new Exception(Language::gc("admin/tools/error8"));
try {
$this->db = new Database("mysql", "pdo", $host, 3306, $user, $pass, null, $name, "utf8mb4", "utf8mb4_unicode_ci");
}
catch (Exception $e) {
throw new Exception(Language::gc("admin/tools/error9", ['{message}' => $e->getMessage()]));
}
// Half of the progress key: a different source database gets a different run.
$this->token = md5($host . "+++" . $user . "+++" . $pass . "+++" . $name);
}
public function disconnect(): void
{
$this->db->disconnect();
}
public function data_types(): array
{
return [
'users' => [
'name' => Language::gc("admin/tools/import-result-users"),
'description' => $this->lang["users-desc"] ?? '',
'required' => [],
],
'invoices' => [
'name' => Language::gc("admin/tools/import-result-invoices"),
'description' => $this->lang["invoices-desc"] ?? '',
// Invoices carry a client id, and only the users map can translate it.
// A type listed here that is not selected too is refused before transfer.
'required' => ['users'],
],
];
}
public function pull_data(string $type = ''): array
{
$table = $type === 'users' ? 'clients' : 'invoices';
$count = $this->db->select("COUNT(id) as total")->from($table)->build();
return [
'status' => "successful",
'count' => $count ? (int) $this->db->getObject()->total : 0,
'type' => $type,
];
}
public function transfer_data(string $type): array
{
$this->initialize_data();
try {
$result = match ($type) {
'users' => $this->users(),
'invoices' => $this->invoices(),
default => [],
};
}
catch (Exception $e) {
// Persist before rethrowing: rows already written keep their mapping,
// so the retry continues instead of importing them a second time.
$this->save_data();
throw $e;
}
$this->save_data();
return $result;
}
public function users(): array
{
$processed = 0;
$last_id = $this->last_id($this->data["users"] ?? []);
$rows = $this->db->select()->from("clients");
$rows->where("id", ">", $last_id);
$rows->order_by("id ASC");
$rows->limit($this->records_per_request);
$rows = $rows->build() ? $rows->fetch_assoc() : [];
foreach ($rows as $row) {
$userId = (int) User::create([
'type' => "member",
'status' => ($row["status"] ?? '') === 'Active' ? "active" : "passive",
'name' => Filter::html_clear($row["firstname"] ?? ''),
'surname' => Filter::html_clear($row["lastname"] ?? ''),
'full_name' => Filter::html_clear(trim(($row["firstname"] ?? '') . ' ' . ($row["lastname"] ?? ''))),
'email' => $row["email"] ?? '',
'creation_time' => $row["created_at"] ?? null,
]);
if (!$userId) continue;
// The map entry is the resume point, the duplicate guard and the key the
// invoice type will use to find this client again. Write it immediately.
$this->data["users"][(int) $row["id"]] = $userId;
$processed++;
}
// Ask the source what is left rather than comparing counts: a row deleted at the
// source mid-run would otherwise keep the loop going forever.
$last_id = $this->last_id($this->data["users"] ?? []);
$done = !$this->db->select("id")->from("clients")->where("id", ">", $last_id)->limit(1)->build();
return ['processed' => $processed, 'done' => $done];
}
public function invoices(): array
{
$processed = 0;
$last_id = $this->last_id($this->data["invoices"] ?? []);
// Clean mode carries source ids across, so it needs an empty table. The check runs
// only on the first chunk: from the second on, the rows found are the ones we wrote.
if ($this->import_type === "clean" && $last_id === 0) {
if (WDB::select("id")->from("invoices")->limit(1)->build())
throw new Exception(Language::gc("admin/tools/import-type-clean-error"));
}
$rows = $this->db->select()->from("invoices");
$rows->where("id", ">", $last_id);
$rows->order_by("id ASC");
$rows->limit($this->records_per_request);
$rows = $rows->build() ? $rows->fetch_assoc() : [];
foreach ($rows as $row) {
// Foreign keys are resolved through the map, never through the source id.
$ownerId = (int) ($this->data["users"][(int) ($row["client_id"] ?? 0)] ?? 0);
if (!$ownerId) continue; // owner not in this run; the prerequisite check prevents it
$data = [
'owner_id' => $ownerId,
'total' => (float) ($row["total"] ?? 0),
'status' => ($row["status"] ?? '') === 'Paid' ? "paid" : "unpaid",
'cdate' => $row["created_at"] ?? null,
];
// Only clean mode preserves the source numbering.
if ($this->import_type === "clean") $data["id"] = (int) $row["id"];
WDB::insert("invoices", $data);
$this->data["invoices"][(int) $row["id"]] = (int) WDB::lastID();
$processed++;
}
$last_id = $this->last_id($this->data["invoices"] ?? []);
$done = !$this->db->select("id")->from("invoices")->where("id", ">", $last_id)->limit(1)->build();
return ['processed' => $processed, 'done' => $done];
}
public function initialize_data($predefined_data = []): void
{
$data = $this->data === null
? (Config::getd("import_" . $this->name . "_" . $this->token) ?: [])
: [];
if (!is_array($data)) $data = [];
$this->data = array_replace_recursive($predefined_data, $data);
}
public function save_data(int|array $overwrite_data = -1): void
{
$data = $this->data ?? [];
if ($overwrite_data !== -1) $data = $overwrite_data;
Config::setd("import_" . $this->name . "_" . $this->token, $data);
}
public function last_id($array): int
{
return $array ? max(array_keys($array)) : 0;
}
}
<?php if (!defined("CORE_FOLDER")) die("403 Forbidden"); ?>
<div class="cred-stack">
<div>
<label for="AcmeBill_Host" class="form-label"><?php echo Language::gc("admin/tools/import-db-host"); ?></label>
<!-- Every input is namespaced by the platform: the whole group arrives as
one array and is handed to connect() as $info. -->
<input id="AcmeBill_Host" type="text" class="form-control" name="AcmeBill[db_host]" value="">
</div>
<div>
<label for="AcmeBill_Name" class="form-label"><?php echo Language::gc("admin/tools/import-db-name"); ?></label>
<input id="AcmeBill_Name" type="text" class="form-control" name="AcmeBill[db_name]" value="" data-role="db-name">
</div>
<div>
<label for="AcmeBill_User" class="form-label"><?php echo Language::gc("admin/tools/import-db-username"); ?></label>
<input id="AcmeBill_User" type="text" class="form-control" name="AcmeBill[db_username]" value="">
</div>
<div>
<label for="AcmeBill_Pass" class="form-label secret-input"><?php echo Language::gc("admin/tools/import-db-password"); ?></label>
<input id="AcmeBill_Pass" type="password" class="form-control" name="AcmeBill[db_password]" value="">
</div>
</div>
// operations/AdminTools.php - how your instance is built and driven, reduced.
$load = Modules::Load("Imports", $platform);
if (!$load) throw new Exception("The platform is not supported. #1");
$instanceKey = "\\WISECP\\Modules\\Imports\\" . $platform;
if (!class_exists($instanceKey)) throw new Exception("The platform is not supported. #2");
$instance = new $instanceKey();
$instance->lang = $load["lang"] ?? [];
$instance->area_link = LinkGenerator::admin("tools-1", ["imports"]);
$instance->name = $platform;
$instance->controller = $this;
if (method_exists($instance, "connect")) $instance->connect($platform_info);
// Prerequisites are enforced at the select step, before a single row moves.
foreach ($data_Types as $selected_type)
foreach (($data_types[$selected_type]["required"] ?? []) as $needed)
if (!in_array($needed, $data_Types, true))
throw new Exception(Language::gc("admin/tools/import-missing-prerequisite", [
'{type}' => $data_types[$selected_type]["name"] ?? $selected_type,
'{needed}' => $data_types[$needed]["name"] ?? $needed,
]));
// The transfer step, once per chunk.
if (property_exists($instance, "selected_data_types")) $instance->selected_data_types = $data_types_s;
if (property_exists($instance, "import_type")) $instance->import_type = $import_type;
if (property_exists($instance, "records_per_request")) $instance->records_per_request = $records_per_request;
$result = $instance->transfer_data($type); // ['processed' => int, 'done' => bool]
if (method_exists($instance, "disconnect")) $instance->disconnect();
Pitfalls
The chunk query skips everything already mapped, so a second run finds no rows and looks like it worked. Clear the map for "start over", never for "resume".
A type that resolves a foreign key through another type's map must name it in required. Leave it out and the map is empty at lookup time: rows land with no owner and nothing throws.
One text filter over the whole group mangles the password and the encryption key, and the failure looks like wrong credentials. Use pass-through for secrets.
Comparing processed rows against the starting total breaks when the source changes mid-migration. Ask what remains past the last mapped id.
Clients, orders, services and invoices have side effects raw inserts skip. Import the global classes explicitly: inside the module namespace an unqualified helper name does not resolve.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.