The big picture
This is a one-way ETL job: Extract from Joomla's MySQL, Transform each row into our shape, Load into MongoDB. We never touch the live Joomla site and never write straight to production until the script has been proven on a throwaway database.
scripts/migrate-joomla/ — reads MySQL, writes via Mongoose models0 Identify where Joomla keeps the listings
Joomla has no built-in "products" table — the data lives in whichever extension the old site used. Everything later depends on this answer, so do it first.
Run in phpMyAdmin on the old site (replace prefix with the real table prefix, e.g. jos_ or a random one like x7k2p_):
SHOW TABLES;
SELECT name, element
FROM prefix_extensions
WHERE type = 'component' AND enabled = 1;
| If you see… | The site uses | Main tables |
|---|---|---|
com_djclassifieds | DJ-Classifieds (most common for classifieds) | djcf_items, djcf_categories, djcf_images |
com_k2 | K2 content | k2_items, k2_categories, k2_extra_fields |
com_virtuemart | VirtueMart shop | virtuemart_products, virtuemart_categories, … |
| none of the above | Core articles + custom fields | content, categories, fields_values |
Users are always in prefix_users regardless of the extension.
1 Take a copy — never work against the live site
- Database — cPanel → phpMyAdmin → Export → SQL format, or on the server:
mysqldump -u USER -p DBNAME > joomla.sql - Images — download as a zip via FTP / File Manager. Usually
/images/plus the extension's own folder, e.g./components/com_djclassifieds/images/or/media/djclassifieds/. - Old URLs — save
sitemap.xmlor export pages from Google Search Console. Needed for redirects in step 7.
2 Restore the dump locally
Run MySQL in Docker on port 3307 so it doesn't clash with anything else:
docker run -d --name joomla-mysql -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=joomla -p 3307:3306 mysql:8
docker exec -i joomla-mysql mysql -uroot -proot joomla < joomla.sql
Open it with DBeaver / HeidiSQL / MySQL Workbench (host localhost, port 3307) and note the columns of the item, category, image and user tables:
SHOW CREATE TABLE prefix_djcf_items;
SELECT COUNT(*) FROM prefix_djcf_items;
SELECT published, COUNT(*) FROM prefix_djcf_items GROUP BY published;
3 Field mapping — decide before writing code
This is where most of the real decisions live. Column names below assume DJ-Classifieds (the typical case) — confirm them against your own dump from step 2.
| Joomla (djcf_categories) | Mongo (Category) | Transform |
|---|---|---|
id | legacyId | Copy as-is (see step 4) |
name | name | Trim, decode HTML entities (& → &) |
alias | slug | Lower-case; must be unique per navigation — add a suffix on clash |
parent_id | parentId | Look up the new _id of the parent from the id map |
| depth in tree | level 1–3 → type main / sub / sub-sub | type is set automatically from level by the model |
| (no equivalent) | navigation new | used | Decision from the root branch, or duplicate the tree for both |
published 1 / 0 | status active / inactive | 1 → active, else inactive |
ordering | categoryNumber | Optional; unique per navigation |
| Joomla (users) | Mongo (User) | Transform |
|---|---|---|
id | legacyId | Copy as-is |
name (single field) | firstName + lastName | Split on first space; sellers require a last name — fallback needed |
username | username | Lower-case; unique |
email | email | Lower-case; unique — Decision for duplicates |
password | password | See password rules below |
block 0 / 1 | status | 0 → active, 1 → inactive |
| owns ≥ 1 listing | role | seller; everyone else buyer (or skip) |
registerDate | createdAt | Keeps "member since" correct |
| profile / custom fields | phone, address, state, companyName, australianABN… | If Joomla stored them (e.g. user_profiles) |
Passwords
| Hash in Joomla | Can old password keep working? | What to do |
|---|---|---|
$2y$10$… (Joomla 3.2+ / 4 bcrypt) | Yes | Rewrite prefix $2y$ → $2b$ so bcryptjs verifies it |
$argon2… | No | Random password + mustChangePassword: true + reset email |
hash:salt (old MD5) | No | Same as above |
.save() for usersThe User model's pre('save') hook bcrypt-hashes the password — it would hash the already-hashed value and nobody could log in. Load users with User.bulkWrite / insertMany, which skip that hook.| Joomla (djcf_items) | Mongo (Listing) | Transform |
|---|---|---|
id | legacyId and listingNo | Keeping the old id as product number means #numbers don't change for anyone |
cat_id | categoryId | From the category id map; missing → skip + report (or isUncategorized) |
| category's navigation | productType | new → new-ads, used → used-product |
user_id | sellerId + addedBy | Owner found → seller; else sellerId: null, addedBy: admin |
name | title | Trim, decode entities |
alias | slug | Lower-case |
price (often text) | price (number) + isPriceNegotiable | "$1,200 ono" → 1200 + negotiable; "POA" → Decision |
currency | currency | Default AUD |
description (HTML) | detailedNotes | Strip tags, keep line breaks |
intro_desc | accessoriesIncluded or prepend to notes | Decision |
| custom fields (brand, model, condition) | manufacturer, modelNumber, workingCondition, physicalCondition | Condition must be new / open / excellent / good / fair / poor — build a lookup |
published, date_exp, sold flag | status | published + not expired → active; sold → sold_out; else inactive |
contact, email, phone | contactFirstName, contactEmail, contactPhone | Admin-only fields (private in API) |
address, region_id, post_code | businessCity, businessState, businessPostalCode | Region id → name via the regions table |
date_start | createdAt | Keeps sorting and dashboard history correct |
getNextListingNo() in counter.model.ts reads the highest existing listingNo and moves the counter past it, so new listings after migration won't collide with imported numbers.| Joomla (djcf_images) | Mongo (Listing.images) | Transform |
|---|---|---|
item_id | parent listing | Group rows by item |
path + name + ext | /uploads/products/<file> | Copy file from the zip into uploads/products/; store the relative path |
ordering | array order | Sort ascending; first becomes the card image |
- Max 5 images — the schema rejects more. Keep the first 5 by ordering.
- Rename files to something collision-proof, e.g.
joomla-<itemId>-<n>.jpg. - Missing file on disk → drop that image and log it; don't store a broken path.
- Joomla often has thumbnails (
_th,_msuffixes) — copy only the original.
Offers, quote requests and questions are usually not worth migrating: they're historical, tied to Joomla's own contact workflow, and often live in email rather than the database.
4 One small schema change: legacyId
Add this field to Category, User and Listing:
legacyId: { type: Number, index: true, sparse: true },
The script then upserts by legacyId instead of blindly inserting. That gives you:
- Safe re-runs — running twice updates, never duplicates.
- Fix-and-retry — find a mapping bug, fix it, run again.
- Relationships — a listing finds its category / seller's new
_idvia the old id. - Redirects — old URL ids map straight to new slugs (step 7).
legacyId in the model you'd need a separate lookup collection.5 The migration script
Lives in scripts/migrate-joomla/, follows the pattern of scripts/create-admin.ts, runs with tsx, and uses mysql2 (dev dependency) to read. It imports our real Mongoose models so schema validation and the image path setters still apply.
Load order (parents before children)
- Categories — sorted by depth so parents exist first; build a
legacyId → _idmap. - Users —
bulkWriteupserts with rewritten hashes (never.save()). - Listings — resolve
categoryId/sellerIdfrom the maps; setcreatedAtfrom Joomla's date. - Images — copy files, set paths, cap at 5.
- Recompute
Category.totalProductswith one aggregation at the end — not the per-insert$inchelpers, which would double-count on re-runs. - Report — rows read / inserted / updated / skipped with reason. Skipped rows go to a CSV for the client. Nothing is dropped silently.
Shape of the script
// scripts/migrate-joomla/index.ts (sketch)
const DRY_RUN = process.argv.includes('--dry-run');
const mysql = await createConnection(process.env.JOOMLA_MYSQL_URL!);
await mongoose.connect(process.env.MONGODB_URL!);
const report = new MigrationReport(); // counts + skipped rows → CSV
const categoryMap = await migrateCategories(mysql, report, DRY_RUN);
const userMap = await migrateUsers(mysql, report, DRY_RUN);
await migrateListings(mysql, { categoryMap, userMap }, report, DRY_RUN);
await recountCategoryTotals(DRY_RUN);
report.print();
report.writeSkippedCsv('migration-skipped.csv');
# run
npx tsx scripts/migrate-joomla/index.ts --dry-run # everything except writes
npx tsx scripts/migrate-joomla/index.ts # for real
JOOMLA_MYSQL_URL, JOOMLA_IMAGES_DIR and MONGODB_URL come from environment variables — so the same script points at local, staging or production just by changing .env.6 Test on a throwaway database first
Point MONGODB_URL at a local or staging copy — never production for the first runs. Then verify:
| Check | How |
|---|---|
| Counts match | Joomla COUNT(*) vs Mongo countDocuments() — difference must equal the skipped count in the report |
| Data looks right | Open 20 random listings side by side (old site vs new) |
| Images load | Browse category pages; no broken thumbnails |
| Login works | Log in as a migrated seller with their old password |
| Category counts | Category pages show the expected product totals |
| No orphans | Listing.countDocuments({ categoryId: null, isUncategorized: false }) → 0 |
| Re-run is safe | Run the script again — counts must not change |
7 Keep old links working (easy to forget)
Google has indexed the Joomla URLs. If they 404 on launch day, search ranking and traffic drop.
- From the migrated data, generate a JSON map
old URL → new URL(you know both vialegacyId→ slug). - Add them as 301 permanent redirects in the Next.js frontend —
redirects()innext.configfor a small list, or middleware reading the JSON map for a large one. - After launch, watch 404 logs and add anything missed.
/component/djclassifieds/?view=item&id=531 → /new-ads/3-microphones/531-sony-ecm-w3
/classifieds/microphones/531-sony-ecm-w3 → /new-ads/3-microphones/531-sony-ecm-w3
8 Cutover day
- Announce a freeze; put Joomla in offline / read-only mode so no new data appears mid-migration.
- Take the final dump + images (repeat step 1).
- Back up production Mongo first:
mongodump --uri "$MONGODB_URL" --out backup-before-migration. - Run the script against production; review the report.
- Switch DNS to the new platform.
- Watch 404 logs and error logs for the first days.
- Archive the Joomla dump for a few months, then delete it (it holds personal data).