EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / DirectAdmin / Migrating vBulletin 4.2.5 to Flarum: Lessons from 200,000 Posts
DirectAdmin

Migrating vBulletin 4.2.5 to Flarum: Lessons from 200,000 Posts

by Ton-Or ·25 Jul 2026·11 min read
Migrating vBulletin 4.2.5 to Flarum: Lessons from 200,000 Posts

A forum that has been running for over a decade is bound to accumulate plenty of data — when the day came to migrate from vBulletin 4.2.5 to Flarum, the question was not "can it be migrated" but which small details would crash an entire import at three in the morning. This article records the pitfalls I ran into myself.

It was time to retire my old vBulletin 4.2.5 forum — development on the codebase had stopped, and I still had to pay license fees, so I decided to move to Flarum, a newer PHP application that is lighter and much easier to theme. The data to move included over seven thousand members, over twenty thousand threads, and over two hundred thousand posts. With work like this, running a script once and assuming everything is done tends to hurt at three in the morning, so I wanted to record the pitfalls that actually cost me time, in case someone migrating their own forum can avoid the same traps.

The database names, usernames, and IP addresses in this article are examples, but the table structures and sequence of steps are real.

The overall plan: do not import everything at once

The first thing to accept is that these two schemas are worlds apart. I could not simply dump the old tables into the new ones; I had to map each group in dependency order — referenced records (users, tags) must always go in before the records that reference them (discussions, posts).

Safe import orderusersOver seven thousand→tags39 original forums→discussionsThreads→postsOver two hundred thousand→metadatacounts/sticky/lock

I converted vBulletin's "forum" into Flarum's "tag" because Flarum has no concept of nested forums; it uses tags for everything. A thread becomes a discussion, and a post remains a post — but the details inside are where the trouble starts.

user_id = 0 and FK failures across an entire batch

The first trap I hit: vBulletin uses userid = 0 for guests (people who posted without logging in, or users who have been deleted), but Flarum has a foreign key requiring posts.user_id to point to an existing user or be NULL. Importing posts with userid=0 directly triggered an FK constraint violation immediately, failing the entire insert batch.

The fix had two parts: convert 0 to NULL during mapping, and disable FK checks during bulk inserts because I was already controlling the order myself.

-- ปิด FK check ทั้ง session ก่อน bulk insert
-- (เราคุมลำดับ users -> posts เองแล้ว ไม่ต้องให้ engine เช็คทีละแถวให้ช้า)
SET FOREIGN_KEY_CHECKS = 0;

INSERT INTO flarum_db.posts (id, discussion_id, user_id, type, content, created_at)
SELECT p.postid,
       p.threadid,
       NULLIF(p.userid, 0) AS user_id,   -- guest 0 -> NULL กันชน FK
       'comment',
       p.pagetext,                        -- ยังเป็น BBCode ตรงนี้ เดี๋ยวแปลงทีหลัง
       FROM_UNIXTIME(p.dateline)
FROM vb_db.post p;

SET FOREIGN_KEY_CHECKS = 1;
After disabling FK checks, turn them back on + verify

Disabling FOREIGN_KEY_CHECKS means trusting that I will not let orphan rows slip in. If I accidentally import posts whose discussion_id points to a thread that has not been migrated yet, the database will silently accept them, only to break when a user opens the thread. After each batch, always compare row counts on both sides.

Old passwords: nested md5 hashes — leave them alone

The question people ask most is "will members have to reset their passwords after the migration?" — the answer is no, if it is done correctly. vBulletin stores passwords as

vBulletin: md5( md5(password) . salt )   -- salt ต่อท้าย hash ชั้นใน

I cannot reverse the hash anyway (it is a hash), so do not re-hash or convert anything at all — copy both the original hash and the original salt directly, then let the migratetoflarum/old-passwords extension verify them when a member first logs in. Set the algorithm to md5-double and the salt position to salt-after (appended) to match vBulletin's formula.

-- ยก hash เดิม + salt เดิมมาทั้งดุ้น ห้ามแปลง
INSERT INTO flarum_db.users (id, username, email, password, joined_at)
SELECT u.userid, u.username, u.email, u.password, FROM_UNIXTIME(u.joindate)
FROM vb_db.user u;

-- salt เดิมเก็บไว้ให้ extension เอาไปต่อท้ายตอนตรวจ (salt-after)
UPDATE flarum_db.users f
JOIN vb_db.user u ON u.userid = f.id
SET f.migratetoflarum_old_password_salt = u.salt;
Verify on first login, then automatically re-hash with bcrypt

On a member's first successful login, the extension checks md5-double against the password entered. If it matches, it automatically re-hashes that password using Flarum's bcrypt and discards the old hash — meaning that over time, the database gradually upgrades passwords to the new standard without members noticing.

Different schemas: where do sticky/lock/approve go?

vBulletin stores thread states (sticky, locked, approved) directly as columns in the thread table, but Flarum keeps its core lean — the discussions table has no is_sticky, is_locked, or is_approved columns at all. These have moved into extensions and a soft-delete pattern instead.

vBulletin

All states live in the thread table

sticky, open (lock), visible (approve) are columns directly in the thread table, making queries easy but bloating the core

Flarum

Split into extensions + soft-delete

Sticky/locked states live in flarum/sticky + flarum/lock, while is_approved becomes hidden_at (NULL = shown, a value = hidden)

That means mapping requires knowing which new column each old value belongs in. For example, sticky and lock must be set after importing discussions:

vBulletinFlarumthread.sticky = 1Stickydiscussions.is_stickyVia flarum/stickythread.open = 0Closed (locked)discussions.is_locked = 1Via flarum/lockthread.visibleApprovedhidden_atNULL = shown, set = hiddenUpdate both tables
vBulletin thread states are columns in the thread table, while Flarum separates them into extensions and hidden_at; hiding content requires updating both the discussions and posts tables
-- ปักหมุด: flarum/sticky อ่านคอลัมน์ is_sticky ที่ extension เพิ่มเข้ามา
UPDATE flarum_db.discussions f
JOIN vb_db.thread t ON t.threadid = f.id
SET f.is_sticky = 1
WHERE t.sticky = 1;

-- ล็อก: flarum/lock ใช้ is_locked ; vBulletin open=0 คือปิด (ล็อก)
UPDATE flarum_db.discussions f
JOIN vb_db.thread t ON t.threadid = f.id
SET f.is_locked = 1
WHERE t.open = 0;
Hidden/private states require updating two tables, not one

Flarum stores the hidden state in both discussions.hidden_at and posts.hidden_at. Updating only the discussions table can still leave content visible to outsiders. The same applies to is_private in fof/byobu — when switching to public, clear it in both discussions and posts, or some people will see the content while others will not, and run php flarum cache:clear afterward every time.

BBCode in two hundred thousand posts must become s9e XML

When inserting posts above, I put in raw pagetext that was still BBCode first. But Flarum does not store raw markdown/BBCode in the content column — it stores parsed s9e XML. When rendering, it reads directly from the XML without parsing again, so leaving raw BBCode in place makes the page display [b]...[/b] literally.

The procedure is to enable the flarum/bbcode extension first, then flush the formatter cache; otherwise, the parser will not recognize BBCode tags yet. Then convert every post through Flarum's own formatter to get XML that properly follows its pipeline.

<?php
// รันด้วย php cli ของ Flarum เอง (bootstrap แล้วจะมี $formatter ให้ใช้)
// เปิด flarum/bbcode ใน admin ก่อน แล้ว flush ไม่งั้น [b] ไม่ถูกตีความ
$formatter->flush();

$rows = DB::table('posts')->select('id', 'content')->orderBy('id')->cursor();
foreach ($rows as $p) {
    // content ตอนนี้ยังเป็น BBCode ดิบ -> parse เป็น s9e XML ที่ Flarum เก็บจริง
    $xml = $formatter->parse($p->content, null);
    DB::table('posts')->where('id', $p->id)->update(['content' => $xml]);
}
Process in batches and log the last id

Parsing over two hundred thousand posts at once takes time and RAM, and if it stops halfway through, it is hard to know how far it got. I split the work into chunks and wrote the last id to a file after each one, so a failed run could resume from where it left off — long-running jobs must always be resumable.

Attachments are on disk, not in the DB

Another point many people miss: in vBulletin 4, the setting attachfile = 2 means attachments are stored on the filesystem, not as BLOBs in the database. Looking through the filedata table therefore shows empty BLOB columns, which can make it seem as though the files are missing — the files are actually on disk, following its storage pattern.

# vBulletin attachfile = 2 = ไฟล์อยู่บนดิสก์ ตาราง filedata เก็บแต่ metadata
# path pattern: ./attachment/vbulletin/<hash>/<filedataid>.attach
find /home/vbforum/attachment -name '*.attach' | wc -l

# ปลายทาง Flarum + fof/upload:
#   ตัวไฟล์  -> public/assets/files/
#   record   -> ตาราง fof_upload_files + fof_upload_file_posts (ผูกไฟล์กับโพสต์)

An .attach file contains the raw bytes of the actual file, just without its original extension. Read the real extension from the extension column in the filedata table, then copy the file out under a new name before registering it with fof/upload and linking it back to the post it was originally attached to.

Running scripts on the server: do not use heredoc

The last lesson came from running the migration itself. I wrote my migration script on my dev machine and then had to run it on the server. At first, I could not be bothered with scp, so I sent PHP directly through an SSH heredoc. Code containing $row["userid"] broke because the shell interfered with $ and the double quotes before PHP could see them, leaving empty variables and errors that took me a while to make sense of.

# อย่า: heredoc ทำ shell แปลง $var["key"] ก่อน PHP ได้เห็น -> ค่าเพี้ยน
ssh [email protected] php <<EOF
  echo \$row["userid"];   // โดน shell กินตั้งแต่ยังไม่ถึง PHP
EOF

# ทำ: scp ไฟล์ขึ้นไปก่อน แล้วสั่งรันเป็นไฟล์ ให้ PHP อ่านซอร์สเองทั้งก้อน
scp migrate_posts.php [email protected]:/home/admin/
ssh [email protected] '/usr/local/php82/bin/php /home/admin/migrate_posts.php'
Clean up after running

Remember to delete temporarily uploaded scripts and intermediate dump files after the job is done. Also, do not hardcode Windows paths (d:\...) in the scripts, because on Linux they become strange filenames that the server's file monitor picks up — run the script as a file through stdin or use Linux paths only.

The sequence I actually used

  1. Map in dependency order: users -> tags -> discussions -> posts -> metadata. Do not load everything at once.
  2. guest userid 0 -> NULL with NULLIF, and disable FOREIGN_KEY_CHECKS during bulk inserts, then re-enable them + compare row counts
  3. Passwords: copy the original hash + salt directly, set old-passwords to md5-double / salt-after, and do not re-hash
  4. sticky/lock/approve are no longer in Flarum core -> flarum/sticky, flarum/lock, and hidden_at (hiding requires updating both discussions and posts)
  5. BBCode -> s9e XML through Flarum's own formatter (enable flarum/bbcode + flush first), in resumable batches
  6. Attachments with attachfile=2 are on disk (.attach), not in BLOBs -> copy + set extensions from filedata.extension -> register with fof/upload
  7. To run on the server, scp the script and execute it as a file. Do not use heredoc, and clean up all temporary files.

Personally, I find that the hard part of migrating a forum is not the data itself, but the small differences between the two schemas — the thing that breaks an import is often not the two hundred thousand posts, but a single row with userid 0 or a salt in the wrong position. If you are migrating your own forum, I recommend always working on a copy of the database first, comparing row counts on both sides for every batch, and only cutting over the live system once you are confident. If I have left anything out or made a typo, I will come back and correct it later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first