This guide shows you how to migrate a large phpBB 3.x forum archive into WordPress using a custom PHP script, batched raw SQL via PDO, and a Docker Compose-based test environment — without relying on a migration plugin. The approach is designed for archives where plugin-based migration either fails or times out at scale.
Prerequisites
- Access to the phpBB 3.x database (see the phpBB 3.x table reference)
- A target WordPress database ready to receive content (see the WordPress database schema)
- PHP 8+ with PDO and the
pdo_mysqlextension enabled - Docker Compose installed
Back up your WordPress database before running any migration script. The approach below bypasses wp_insert_post() deliberately — see the trade-offs section at the end.
Docker test environment
Never run a migration script directly against a production database. A three-container Docker Compose setup lets you reset to a clean state on every test run — import the phpBB dump into one container, a fresh WordPress dump into another, and run the PHP migrator against both:
services:
phpbb_source:
image: mysql:8.0.36
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: phpbb_source
volumes:
- ./phpbb_dump.sql:/docker-entrypoint-initdb.d/dump.sql
wp_target:
image: mysql:8.0.36
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wp_target
volumes:
- ./wp_fresh.sql:/docker-entrypoint-initdb.d/dump.sql
migrator:
image: php:8.2-cli
depends_on: [phpbb_source, wp_target]
volumes:
- .:/app
working_dir: /app
command: php migrate.php
Older phpBB installs commonly use utf8_general_ci, while WordPress defaults to utf8mb4_unicode_ci. Check both schemas before migrating — a mismatch will corrupt emoji and multi-byte characters silently rather than throwing an error. Convert the source dump to utf8mb4 first if it isn’t already.
Re-import the WordPress dump between test runs to verify the script is idempotent. Before the migration loop starts, add CREATE INDEX statements on the columns you join or filter on:
CREATE INDEX idx_topic_id ON phpbb_topics(topic_id);
CREATE INDEX idx_forum_id ON phpbb_topics(forum_id);
CREATE INDEX idx_post_time ON phpbb_topics(post_time);
Indexing temporary tables before large joins cuts execution time significantly on archives with tens of thousands of rows.
Open two PDO connections
The migration script opens two simultaneous PDO connections — one to read from phpBB, one to write to WordPress:
$phpbb = new PDO('mysql:host=phpbb_source;dbname=phpbb_source', 'root', 'root');
$phpbb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$wp = new PDO('mysql:host=wp_target;dbname=wp_target', 'root', 'root');
$wp->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
Table mapping
The core mapping from phpBB 3.x to WordPress:
| phpBB source | WordPress target |
|---|---|
phpbb_topics.topic_title |
wp_posts.post_title |
phpbb_topics.topic_time (Unix) |
wp_posts.post_date (datetime) |
First phpbb_posts.post_text per topic |
wp_posts.post_content |
phpbb_posts.post_text (replies) |
wp_comments.comment_content |
phpbb_forums.forum_name |
wp_terms.name (category) |
phpbb_users.username |
wp_posts.post_author via wp_users |
phpBB stores timestamps as Unix integers. Convert them at insert time:
date('Y-m-d H:i:s', $row['topic_time'])
Mapping authors before the insert loop
Build a username-to-author lookup once, before the batch loop starts, rather than querying per row. Create placeholder WordPress users for any phpBB username that doesn’t already have a matching wp_users row, then map by username:
$authorMap = [];
$stmt = $wp->query("SELECT ID, user_login FROM wp_users");
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $user) {
$authorMap[$user['user_login']] = $user['ID'];
}
// Fall back to user ID 1 (admin) only for usernames with no WordPress account
Batched insert loop
Process topics in 500-row batches using LIMIT/OFFSET to keep memory usage flat regardless of archive size. The author lookup from the previous step resolves each post to its original phpBB author instead of defaulting everything to the admin account, and post_name is generated from the title so permalinks resolve correctly:
$batchSize = 500;
$offset = 0;
do {
$stmt = $phpbb->prepare(
"SELECT t.topic_id, t.topic_title, t.topic_time, p.post_text, u.username
FROM phpbb_topics t
JOIN phpbb_posts p ON p.post_id = t.topic_first_post_id
JOIN phpbb_users u ON u.user_id = p.poster_id
LIMIT :limit OFFSET :offset"
);
$stmt->bindValue(':limit', $batchSize, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$slug = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $row['topic_title']), '-'));
$authorId = $authorMap[$row['username']] ?? 1;
$insert = $wp->prepare(
"INSERT IGNORE INTO wp_posts
(post_title, post_content, post_date, post_name, post_status, post_type, post_author)
VALUES (:title, :content, :date, :name, 'publish', 'post', :author)"
);
$insert->execute([
':title' => $row['topic_title'],
':content' => $row['post_text'],
':date' => date('Y-m-d H:i:s', $row['topic_time']),
':name' => $slug,
':author' => $authorId,
]);
}
$offset += $batchSize;
} while ( count($rows) === $batchSize );
INSERT IGNORE combined with a unique index on post_name lets you re-run the script against the same WordPress database without creating duplicate posts — essential for the idempotency check described in the Docker section above.
Map threaded replies from phpbb_posts to wp_comments using the same batched pattern, joining on topic_id to associate each comment with its parent post:
$insert = $wp->prepare(
"INSERT INTO wp_comments
(comment_post_ID, comment_author, comment_date, comment_content, comment_approved)
VALUES (:post_id, :author, :date, :content, 1)"
);
$insert->execute([
':post_id' => $wpPostIdForTopic[$row['topic_id']],
':author' => $row['username'],
':date' => date('Y-m-d H:i:s', $row['post_time']),
':content' => $row['post_text'],
]);
$wpPostIdForTopic is a lookup built during the posts batch above, mapping each phpBB topic_id to the WordPress post ID it produced — build it by capturing $wp->lastInsertId() after each post insert.
Assigning categories
The table mapping above maps each phpBB forum to a WordPress term in wp_terms, but a term row alone doesn’t categorise anything. Each migrated post also needs a row in wp_term_relationships linking object_id (the post ID) to the term_taxonomy_id from wp_term_taxonomy for that forum’s term, with term_taxonomy set to category. Insert the term and its taxonomy row once per forum before the batch loop, then insert one wp_term_relationships row per post during the loop.
Why raw SQL instead of wp_insert_post()
wp_insert_post() is the correct WordPress API for inserting posts — it fires action hooks, handles sanitisation, and updates term counts automatically. For a one-time migration of 60,000 topics, bypassing it is a deliberate trade-off: at this scale the per-call overhead and cache flushes add hours of runtime. Use raw SQL only for bulk historical data migration. After the script completes, run wp recount terms and wp cache flush via WP-CLI to restore consistency. For smaller migrations or ongoing content creation, always use wp_insert_post().
Privacy
Do not migrate email addresses or password hashes — doing so without user consent violates GDPR and similar regulations. Migrate display names and post associations only. Create placeholder WordPress users mapped to phpBB usernames if you need authorship attribution.
Run the full migration on your Docker environment first. Verify post counts, category assignments, and a sample of threaded comments before applying to production. The Docker setup is your proof of correctness — treat it as mandatory, not optional.