Joomla → BroadcastAV migration guide

How to move the existing Joomla data (categories, sellers, listings and images) out of MySQL and into our MongoDB platform safely. Targets are the real Mongoose models in broadcastav-api/src/models.

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.

Source
Live Joomla
MySQL database + image folders on the old hosting
Extract
SQL dump + images zip
Read-only copy, downloaded via cPanel / FTP
Local
MySQL in Docker
Restore the dump, explore tables safely
Transform + Load
Migration script
scripts/migrate-joomla/ — reads MySQL, writes via Mongoose models
Target
MongoDB
Staging first, production only at cutover
The four things that usually go wrong Unmapped or oddly formatted fields · broken images · lost SEO links · duplicate records from re-running the script. Every step below exists to prevent one of these.

0 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 usesMain tables
com_djclassifiedsDJ-Classifieds (most common for classifieds)djcf_items, djcf_categories, djcf_images
com_k2K2 contentk2_items, k2_categories, k2_extra_fields
com_virtuemartVirtueMart shopvirtuemart_products, virtuemart_categories, …
none of the aboveCore articles + custom fieldscontent, categories, fields_values

Users are always in prefix_users regardless of the extension.

1 Take a copy — never work against the live site

  1. Database — cPanel → phpMyAdmin → Export → SQL format, or on the server:
    mysqldump -u USER -p DBNAME > joomla.sql
  2. 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/.
  3. Old URLs — save sitemap.xml or export pages from Google Search Console. Needed for redirects in step 7.
Treat the dump as sensitiveIt contains customer names, emails, phones and password hashes. Don't commit it to git or share it in chat — keep it on your machine only and delete it after the project.

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
idlegacyIdCopy as-is (see step 4)
namenameTrim, decode HTML entities (&amp; → &)
aliasslugLower-case; must be unique per navigation — add a suffix on clash
parent_idparentIdLook up the new _id of the parent from the id map
depth in treelevel 1–3 → type main / sub / sub-subtype is set automatically from level by the model
(no equivalent)navigation new | usedDecision from the root branch, or duplicate the tree for both
published 1 / 0status active / inactive1 → active, else inactive
orderingcategoryNumberOptional; unique per navigation
Watch outOur tree allows at most 3 levels under a navigation. If Joomla nests deeper, decide which levels to merge before running anything.

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:

Why it must be in the schemaMongoose runs in strict mode — any field not in the schema is silently dropped on write. Without 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)

1 · Categories→ 2 · Users→ 3 · Listings→ 4 · Images→ 5 · Recount totals→ 6 · Report
  1. Categories — sorted by depth so parents exist first; build a legacyId → _id map.
  2. Users — bulkWrite upserts with rewritten hashes (never .save()).
  3. Listings — resolve categoryId / sellerId from the maps; set createdAt from Joomla's date.
  4. Images — copy files, set paths, cap at 5.
  5. Recompute Category.totalProducts with one aggregation at the end — not the per-insert $inc helpers, which would double-count on re-runs.
  6. 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
Config via env, not hard-codedJOOMLA_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:

CheckHow
Counts matchJoomla COUNT(*) vs Mongo countDocuments() — difference must equal the skipped count in the report
Data looks rightOpen 20 random listings side by side (old site vs new)
Images loadBrowse category pages; no broken thumbnails
Login worksLog in as a migrated seller with their old password
Category countsCategory pages show the expected product totals
No orphansListing.countDocuments({ categoryId: null, isUncategorized: false }) → 0
Re-run is safeRun 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.

  1. From the migrated data, generate a JSON map old URL → new URL (you know both via legacyId → slug).
  2. Add them as 301 permanent redirects in the Next.js frontend — redirects() in next.config for a small list, or middleware reading the JSON map for a large one.
  3. 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

  1. Announce a freeze; put Joomla in offline / read-only mode so no new data appears mid-migration.
  2. Take the final dump + images (repeat step 1).
  3. Back up production Mongo first: mongodump --uri "$MONGODB_URL" --out backup-before-migration.
  4. Run the script against production; review the report.
  5. Switch DNS to the new platform.
  6. Watch 404 logs and error logs for the first days.
  7. Archive the Joomla dump for a few months, then delete it (it holds personal data).

Progress checklist