Repository navigation
Module: Migrator
The Migrator module imports content from another CMS into oCMS, through a pluggable source architecture. Each supported platform is a "source" that reads its own database and writes into oCMS pages, taxonomy, media and users.
Supported sources:
| Source | Platform | Imports |
|---|---|---|
| Elefant CMS | MySQL-backed PHP CMS | Users, tags, pages, posts, media |
| Drupal | Drupal 8/9/10/11 MySQL | Users, tags, categories, media, nodes, URL aliases, redirects, menus |
| PHP-Nuke | PHP-Nuke MySQL | Story authors, topics, categories, media, news stories, static pages, encyclopedias |
Everything an import creates is recorded, so a whole import can be undone from the admin UI later. See Module System for how modules are registered and toggled.
Imports reserve the core RSS/Atom paths (/rss.xml, /atom.xml, category and
tag subscriptions, and language-prefixed equivalents). Legacy aliases and
redirects cannot shadow these routes. See Feeds for the endpoint list.
The module is inactive by default outside development — it declares development as its only allowed environment. It takes source database credentials and a filesystem path as input and writes directly to content tables, which is why it is scoped this way.
That is a default, not a lock. The allowed-environment list is consulted only the first time a module is seen, when its row is created in the modules table; from then on the stored active flag wins. So on a production site the module still appears in Admin > Modules, listed as inactive, and an administrator can switch it on for a one-off migration. Its routes stay closed while it is inactive.
In production, activating it additionally requires OCMS_MIGRATOR_ALLOWED_DB_HOSTS to be set — the admin UI refuses the toggle without it, and the server refuses to start if the module is active while the allowlist is empty. See Importing into production below, where that second rule has an ordering consequence.
All routes are admin-only. Editors cannot reach /admin/migrator, even though other module admin pages are editor-accessible.
Access at Admin > Migrator or /admin/migrator. The dashboard lists every registered source; clicking one opens its configuration form. Form fields come from the source itself, so each platform shows only what it needs.
- Fill in the connection details for the source database.
- Click Test Connection. This connects and counts rows in the expected tables, so a wrong table prefix fails here rather than midway through an import.
- Choose what to import from the checkboxes. Only the options the selected source actually supports are shown.
- Click Start Import and confirm.
Passwords are never stored. The form round-trips through the session so a failed connection test does not clear your input, but the password field is deliberately blanked and must be re-entered.
An import runs as a background job, not inside the request — a real-sized site takes far longer than the 30-second request timeout. You get an immediate "import started" message, and a status card polls for progress. Closing the browser tab does not stop the import.
The card shows the current phase and running counts, and offers a Refresh link while the job is active. When the job finishes, polling stops on its own and the imported-content panel updates in place.
Only one import may run per source at a time; different sources can run concurrently. A single import is stopped after six hours, and the newest 20 finished jobs per source are kept.
An import never stays "running" forever. On a clean shutdown the module cancels running imports, waits up to 4 seconds (the 7-second module shutdown budget minus 3 seconds) for them to stop, then marks the remaining ones interrupted. If the process dies instead, a job whose heartbeat is more than two minutes old is marked interrupted the next time the server starts or the next import begins.
| Method | Path | Description |
|---|---|---|
| GET | /admin/migrator |
List registered sources |
| GET | /admin/migrator/{source} |
Source configuration form |
| GET | /admin/migrator/{source}/status |
Job-status fragment (polled while running) |
| POST | /admin/migrator/{source}/test |
Test connection |
| POST | /admin/migrator/{source}/import |
Start a background import |
| POST | /admin/migrator/{source}/delete |
Delete everything imported from that source |
Not every platform can supply every kind of content, so the form renders only what the selected source reads:
| Option | Elefant | Drupal | PHP-Nuke |
|---|---|---|---|
| Import tags | ✅ | ✅ | ✅ |
| Import categories | — | ✅ | ✅ |
| Import media files | ✅ | ✅ | ✅ |
| Import blog posts | ✅ | ✅ | ✅ |
| Import pages | ✅ | ✅ | ✅ |
| Import menus | — | ✅ | — |
| Import users | ✅ | ✅ | ✅ |
| Skip existing items | ✅ | ✅ | ✅ |
Elefant has no vocabulary or navigation tables. PHP-Nuke keeps navigation in blocks as PHP snippets rather than data, so it has no menus to read either.
Imported accounts are always created with the public role, which grants no admin access, and with a password nobody holds — source password hashes cannot be verified by oCMS and are never carried over. oCMS has no password reset, so an administrator must set a new password under Admin > Users before an imported user can sign in.
| Field | Required | Env default |
|---|---|---|
| MySQL host / port | ✅ |
ELEFANT_HOST, ELEFANT_PORT
|
| MySQL user / password | ✅ |
ELEFANT_USER, ELEFANT_PASSWORD
|
| Database name | ✅ | ELEFANT_DB |
| Table prefix | — | ELEFANT_PREFIX |
| Files path | — |
ELEFANT_FILES — the Elefant files/ directory |
Old Elefant page paths are preserved as page aliases, so existing URLs keep working.
| Field | Required | Env default |
|---|---|---|
| MySQL host / port | ✅ |
DRUPAL_HOST, DRUPAL_PORT
|
| MySQL user / password | ✅ |
DRUPAL_USER, DRUPAL_PASSWORD
|
| Database name | ✅ | DRUPAL_DB |
| Table prefix | — | DRUPAL_PREFIX |
| Files path | — |
DRUPAL_FILES — usually sites/default/files
|
| Public files URL | — |
DRUPAL_PUBLIC_FILES_URL — only needed when the public files URL prefix is not /sites/<site>/files/
|
| Content type mapping | — |
DRUPAL_TYPE_MAP, e.g. article:post,page:page
|
| Tag vocabularies | — |
DRUPAL_TAG_VOCABULARIES — every other vocabulary becomes categories |
Verified against Drupal 11.4.x. The schema is detected at runtime, so contrib-heavy and older 8/9/10 installs work too. Drupal path aliases and redirects are imported, and multilingual sites import each entity in its own language where oCMS has a matching active language — see Multi-Language.
| Field | Required | Env default |
|---|---|---|
| MySQL host / port | ✅ |
PHPNUKE_HOST, PHPNUKE_PORT
|
| MySQL user / password | ✅ |
PHPNUKE_USER, PHPNUKE_PASSWORD
|
| Database name | ✅ | PHPNUKE_DB |
| Table prefix | — |
PHPNUKE_PREFIX — defaults to nuke_, but it is chosen at install time and is often site-specific |
| Document root | — |
PHPNUKE_FILES — the site root, not an uploads directory |
| Target language | — |
PHPNUKE_LANGUAGE — defaults to the site language |
How content maps across:
-
News stories → posts. PHP-Nuke splits an article between
hometext(the front-page teaser) andbodytext(the rest); both are imported, and the teaser also becomes the page summary. The original publication date is preserved. - Topics → categories, story categories → tags. A story carries both, so each becomes a different oCMS taxonomy rather than one overwriting the other. Existing terms are reused rather than duplicated, matched on name and language rather than on slug — two labels can slugify to the same thing without being the same term. See Taxonomy.
- Static pages → pages. Inactive pages import as drafts.
- Encyclopedias → one page each, with all their terms rendered as a definition list. PHP-Nuke serves each term from its own query-string URL, which has no oCMS equivalent.
-
Story authors → users. Only accounts actually credited on a story are imported — a long-lived PHP-Nuke user table is mostly dormant registrations. Posts are attributed to the submitter where possible. Credits are read from both
usersandauthors, the separate table PHP-Nuke keeps for accounts allowed to publish, so an administrator who never held an ordinary account keeps their byline. Where a name appears in both, theusersprofile wins field by field — gaps in it, such as a blank email, are filled from the admin row rather than lost. Administrators sharing one webmaster address are imported as separate accounts rather than merged, since a merge cannot be undone; a job notice names them so you can merge by hand. - Media. Only files the content actually references are imported into the Media Library, resolved beneath the document root, and inline image links are rewritten. A PHP-Nuke site root also holds theme furniture, banners and smilies; importing all of it would fill the library with files nothing mentions. Referenced files that no longer exist on disk are reported and their markup is left alone. Only the attribute a reference came from is rewritten, so a path that also appears in prose or inside an external link survives untouched.
Legacy character sets. PHP-Nuke predates UTF-8 defaults, so a non-English site almost always stores text in a single-byte charset —
cp1251for Russian,latin2for Central European, and so on. This is handled automatically: the connection asks forutf8mb4and MySQL converts on read. It matters because the failure mode is silent — if the connection ever negotiates the server default instead, every non-ASCII character arrives as a literal?and the import still reports success. If you verify the source data by hand, note that themysqlCLI's--default-character-setflag is ignored by some 5.x servers; runSET NAMES utf8mb4explicitly before concluding the data is corrupt.
PHP-Nuke serves content from query strings such as modules.php?name=News&file=article&sid=12, which cannot be expressed as a page alias, so no URL aliases are created. Preserving old inbound links is a Reverse Proxy concern.
Not imported: phpBB forums, the 4nAlbum photo gallery, comments, links, downloads, FAQs, journals, blocks, banners, polls, and the statistics tables.
A source database usually lives on a developer's machine, not somewhere the production server can reach — so the practical question is how to get migrated content onto a live site that already has content worth keeping.
Running the migrator on the production server, with the source database reached through a reverse SSH tunnel, is generally the best answer. Content merges into the live site natively, slug collisions are suffixed rather than overwritten, media is imported at full fidelity, and the per-source undo below still works. Because an import is a background job rather than a request, it is not subject to the server's request timeout, which is what makes this preferable to exporting an archive and uploading it (see Import-Export for that route and its size limits).
-
Back up first. On a Plesk deployment,
backup-multi.shtakes a checked SQLite snapshot and a tar ofuploads/; run it by hand rather than waiting for the nightly cron. -
Copy the source files to a temporary directory on the server, e.g.
rsync -az /path/to/legacy-site/ user@server:/tmp/legacy-src/. -
Set the environment on the instance and restart it:
OCMS_MIGRATOR_ALLOWED_DB_HOSTS=127.0.0.1and the source's files variable, e.g.PHPNUKE_FILES=/tmp/legacy-src. The files variable does double duty — it authorizes the path as a trusted media root and pre-fills the form field. -
Open a reverse tunnel from the machine holding the database:
ssh -N -R 3307:127.0.0.1:3306 user@server. The server's127.0.0.1:3307now reaches it.ssh -Rbinds to loopback on the remote by default, so nothing is exposed publicly. -
Activate the module, test the connection, and run the import, using host
127.0.0.1and port3307. Leave Skip existing on for a first run against live content.
Tear down in this order. Deactivate the module in Admin > Modules first, and only then remove
OCMS_MIGRATOR_ALLOWED_DB_HOSTSfrom the environment. Doing it the other way round leaves the site with an active migrator and no allowlist, which makes the server refuse to start. Afterwards, close the tunnel and delete the temporary source directory.
If a long-lived SSH session is unwelcome, the alternative is to mysqldump the source, load it
into a temporary database on the server itself, point the migrator at 127.0.0.1:3306, and drop
it when finished.
Admin > Migrator > source > Delete All Imported Content removes everything that import created. Content you made yourself is never touched.
Some things are deliberately kept even though the import created them, because deleting them would damage content the import does not own:
- A tag, category or media item your own content has started using. Removing it would silently strip that association, or blank a featured image, on a page you wrote.
- A user who still owns content. Two imports sharing an email address is the usual way to reach this — the second reuses the account the first created.
- A menu that already existed, or one that still holds an item you added by hand.
- A menu item you pointed at an imported page. It is given the page's URL rather than being left with an empty destination.
Media files are removed after the database rows are deleted. If a file cannot be removed, it stays on a retry queue and is attempted again on startup and on later deletes, with a warning shown — it is never silently forgotten.
| Variable | Description | Default |
|---|---|---|
OCMS_MIGRATOR_ALLOWED_DB_HOSTS |
Comma-separated hosts a source may connect to (bare hostnames or IPs; no scheme, no port) | — (no restriction) |
OCMS_REQUIRE_MIGRATOR_ALLOWED_DB_HOSTS |
Refuse startup when the module is active with an empty allowlist |
true in production |
OCMS_MIGRATOR_ALLOWED_FILE_ROOTS |
Comma-separated absolute roots that a Files path must sit inside | — |
DRUPAL_FILES, ELEFANT_FILES and PHPNUKE_FILES are treated as trusted file roots too. With no trusted root configured, database-only migration still works while local media import fails closed.
See Configuration for the full environment variable reference.
- Admin-only routes. In demo mode the pages remain viewable, but every action — test, import and delete — is blocked.
- Host allowlist, not a private-IP denylist — a database being migrated from almost always lives on a private address, so denying RFC1918 would break the feature's main purpose.
-
Connection strings are never assembled by string formatting. A database name such as
db?allowAllFiles=truewould otherwise inject driver parameters and let a hostile MySQL server read files from the oCMS host. -
Table prefixes are restricted to
[A-Za-z0-9_], at most 20 characters (anything else is rejected), before reaching SQL; every other value is a bound parameter. - Source passwords are never stored or logged. Imports log the source, the acting user and the chosen options only.
- File access is capability-scoped. After validation, files are read through a directory handle that cannot escape the trusted root, so symlink tricks and traversal paths are rejected.
- Only an allowlist of file types can be imported: JPEG, PNG, GIF, WebP, PDF, MP4, WebM.
- Imported HTML is always sanitized to the same policy the admin editor enforces, regardless of site settings.
- Module System — how modules are registered, activated and extended
- Import-Export — moving content between oCMS instances (a different tool for a different job)
- Media Library, Taxonomy, Multi-Language
- Configuration — environment variable reference
Getting Started
Core Features
- Content Management
- Media Library
- Taxonomy
- Menu Builder
- Forms
- Multi-Language
- SEO
- Feeds
- Video Embedding
- Agent-Ready
Administration
API & Integrations
Security
Theme Development
Module Development
Built-in Modules
- Module: Sentinel
- Module: Developer
- Module: DB Manager
- Module: Migrator
- Module: Informer
- Module: Demo Mode
- Module: Internal Analytics
- Module: External Analytics
- Module: Privacy
- Module: Embed
- Module: MCP Server
Deployment