# Platform Notes Where each platform keeps content, users, URLs, files and schedules, and the traps specific to it. Table and column names below are the platform defaults; confirm them against the actual install (prefixes, versions and plugins change them) before writing export queries. ## Contents - [WordPress](#wordpress) - [Drupal 8 and later](#drupal-8-and-later) - [Joomla 4 and 5](#joomla-4-and-5) - [Magento 2](#magento-2) - [Laravel](#laravel) - [Symfony](#symfony) - [CodeIgniter 4](#codeigniter-4) - [Plain PHP](#plain-php) ## WordPress Checked on WordPress 7.1 with a full migration (see `SKILL.md`, Phase 4 and 6). - **Prefix:** `$table_prefix` in `wp-config.php` (default `wp_`). Use the real prefix in every query. - **Content:** `wp_posts`. Filter `post_type IN ('post', 'page')` (plus any custom types the site uses) and `post_status = 'publish'`; `post_name` is the slug, `post_date_gmt` / `post_modified_gmt` are UTC. Rows with `post_type = 'revision'`, `'attachment'` or `'nav_menu_item'` are not pages. - **Rendered HTML:** `post_content` holds block comments (``) and shortcodes (`[gallery ids="1,2"]`) that only WordPress can expand. Export the rendered HTML with WordPress itself while the old site still runs. This script, run with `wp eval-file export.php` at the WordPress root, writes JSON lines ready for the converter in `data-import.md` (tested on WordPress 7.1): ```php ID, '_elementor_edit_mode', true) === 'builder') { return \Elementor\Plugin::$instance->frontend->get_builder_content_for_display($p->ID, true); // page builders bypass the_content } return apply_filters('the_content', $p->post_content); }; $extras = function ($p) { // plugin data that belongs to the page; each key is a decision in the feature map $x = []; if (function_exists('pll_get_post_language')) { $x['language'] = pll_get_post_language($p->ID); $x['translations'] = pll_get_post_translations($p->ID); } if (function_exists('get_fields')) { $x['fields'] = get_fields($p->ID) ?: null; } // Advanced Custom Fields, structured $x['seoTitle'] = get_post_meta($p->ID, '_yoast_wpseo_title', true) ?: null; // Yoast SEO $x['seoDescription'] = get_post_meta($p->ID, '_yoast_wpseo_metadesc', true) ?: null; return array_filter($x, fn($v) => $v !== null && $v !== '' && $v !== false && $v !== []); }; $pages = fopen('export/pages.jsonl', 'w'); foreach (get_posts(['post_type' => 'page', 'post_status' => 'publish', 'numberposts' => -1, 'orderby' => 'ID', 'order' => 'ASC']) as $p) { fwrite($pages, json_encode(['legacyId' => $p->ID, 'slug' => $p->post_name, 'title' => $p->post_title, 'bodyHtml' => $render($p), 'published' => true, 'updatedAt' => strtotime($p->post_modified_gmt . ' UTC')] + $extras($p), $opts) . "\n"); } $news = fopen('export/news.jsonl', 'w'); foreach (get_posts(['post_type' => 'post', 'post_status' => 'publish', 'numberposts' => -1, 'orderby' => 'ID', 'order' => 'ASC']) as $p) { fwrite($news, json_encode(['legacyId' => $p->ID, 'slug' => $p->post_name, 'title' => $p->post_title, 'summary' => $p->post_excerpt !== '' ? $p->post_excerpt : null, 'bodyHtml' => $render($p), 'publishedAt' => strtotime($p->post_date_gmt . ' UTC'), 'authorLegacyId' => (int) $p->post_author] + $extras($p), $opts) . "\n"); } $users = fopen('export/users.jsonl', 'w'); // identity data only: never user_pass foreach (get_users(['orderby' => 'ID']) as $u) { fwrite($users, json_encode(['legacyId' => $u->ID, 'email' => strtolower(trim($u->user_email)), 'displayName' => $u->display_name, 'role' => $u->roles[0]], $opts) . "\n"); } ``` The converter ignores keys it is not given, so the extra keys (`language`, `translations`, `fields`, `seoTitle`, `seoDescription`) are available for the pages that need them without changing the backend types. After the export, check two things: shortcodes whose plugin is inactive stay as literal text (`grep -oh '\[[a-z0-9_-]*' export/*.jsonl | sort | uniq -c`), and rendered markup that still depends on PHP (`grep -o -E '