Appearance
Migrate from WordPress
Introduction
kitloom runs on WordPress's database as it is. A move is not an export and an import: the same tables get a new application in front of them, with the same content, addresses, users and passwords. That makes the move easy to rehearse — you can look at your site in kitloom, compare it with WordPress page by page and fix what differs long before anyone outside notices.
The steps below keep the live WordPress untouched until the last one:
| Step | What happens to the live site |
|---|---|
| 1. Take stock | nothing |
| 2. A first look, read-only | kitloom reads its database |
| 3. Compare page by page | both sites are read |
| 4. An AI agent's pass | both sites are read |
| 5. A copy for everything that writes | one copy of the database is taken |
| 6. Rehearse the switch | nothing |
| 7. Switch | the switch |
What kitloom reads from WordPress and its plugins, and what it leaves behind, is on WordPress Compatibility.
1. Take stock
Write down what the site runs on before touching anything:
bash
wp core version
wp theme list --status=active
wp plugin list --status=active
wp post-type list --fields=name,public
wp db query "SELECT post_type, post_status, COUNT(*) FROM wp_posts GROUP BY 1, 2"- Plugins. Find each active plugin in the compatibility tables. A plugin with a module keeps its data and its behaviour. A plugin without one keeps only its data in the database: its shortcodes, blocks and pages are not drawn. The comparison in step 3 shows where they were used.
- Post types and taxonomies. Those made with ACF are read from the database. Those a plugin or the theme registers in PHP (
register_post_type()) are unknown to kitloom until a module, the theme orkitloom-site.typesdeclares them; until then their addresses answer 404. - The theme. WordPress themes are PHP written for WordPress and do not run in kitloom. Take a theme from the catalog for the first look and plan a theme of your own — generated or made from an HTML template.
- Code of your own.
functions.php, a site plugin, mu-plugins: what they do (a post type, a shortcode, a redirect, an email) becomes a module of the site. - What lives outside the database. Uploaded files, cron jobs, the mail setup, server rules in
.htaccessor nginx.
This guide is for one WordPress site — not a network (multisite).
2. A first look, read-only
kitloom can draw the public site straight from the live database while it is only allowed to read it. WordPress goes on as it is, and kitloom shows the same posts at the same moment — which is also what makes the comparison in the next step exact.
A user that can only read
Create a MySQL user that can read the WordPress database and change nothing in it. It is safe to hand to whoever does the migration — an agency, a freelancer, an AI agent:
sql
CREATE USER 'kitloom_read'@'%' IDENTIFIED BY 'a-long-random-password';
GRANT SELECT ON wordpress.* TO 'kitloom_read'@'%';wordpress is the database name from wp-config.php (DB_NAME); narrow '%' to the address kitloom connects from. If the host has a read replica, point the user there. When MySQL only listens on the server itself, reach it through an SSH tunnel and connect to 127.0.0.1:3307:
bash
ssh -N -L 3307:127.0.0.1:3306 [email protected]An application that writes nothing
Create the application — the Filament skeleton here, the Nova one works the same way (see Installation):
bash
composer create-project kitloom/filament-skeleton my-site --repository=https://packages.kitloom.dev --add-repository
cd my-siteThe installer asks which modules and themes to keep. Keep the theme the site will be drawn with and let it build the themes — or keep none and look at the pages as JSON. It runs its migrations on the skeleton's own SQLite file, before .env points anywhere else, so the WordPress database is not touched.
Point it at the WordPress database, and move what kitloom keeps in tables of its own — sessions, the cache, the queue, Pulse's metrics — off the database:
ini
# .env
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=db.example.com
DB_DATABASE=wordpress
DB_USERNAME=kitloom_read
DB_PASSWORD=a-long-random-password
DB_PREFIX=wp_ # $table_prefix from wp-config.php
# Nothing of kitloom's own is written to the database
SESSION_DRIVER=array
CACHE_STORE=array
QUEUE_CONNECTION=sync
PULSE_ENABLED=false
# Images from the live site, so nothing needs copying
MEDIA_UPLOADS_URL=https://example.com/wp-content/uploadsLeave out the one module that writes on a page view: kitloom/redirects reads Rank Math's table of redirections on every address — a table a site without Rank Math does not have — and writes each missing page to its 404 log. In composer.json:
json
"extra": {
"laravel": {
"dont-discover": ["kitloom/redirects", "kitloom/redirects-filament"]
}
}(kitloom/redirects-nova with Nova.) Then:
bash
php artisan package:discover
php artisan serveDo not run php artisan migrate or php artisan db:seed here: the first needs a user that may write and belongs to the copy in step 5, the second fills an empty site with demo content.
What you see
Open http://localhost:8000. Every public address works — posts, pages, archives, search, a missing page, robots.txt, the sitemaps, the REST API under /wp-json/ — drawn by the theme you kept: WordPress's theme does not run in kitloom, and the WordPress database has no kitloom_theme option, so APP_THEME in .env decides — the installer wrote the theme you made active there (APP_THEME=Journal). kitloom builds every link from APP_URL, not from WordPress's home option, so nothing leads back to the live site except the images. The options are read on every request, so a change made in wp-admin shows at once.
What kitloom understood of a page, whatever the theme draws: the same address with Accept: application/json gives the kind of page, the template it would be drawn with and its parts (see Headless):
bash
curl -H 'Accept: application/json' http://localhost:8000/about/What does not work in this mode is everything that writes: the admin and signing in (roles live in kitloom's own tables), forms, comments, the cart, and Rank Math's redirects, whose module is left out. Those wait for the copy in step 5.
3. Compare page by page
compare-sites.php reads the WordPress site and kitloom address by address and writes down what differs. It is one PHP file with no dependencies — PHP 8.2 or newer with curl and dom — and it only reads: nothing is sent to either site but GET requests.
bash
curl -O https://kitloom.dev/migrate/compare-sites.php
php compare-sites.php --old=https://example.com --new=http://localhost:8000 --save-htmlWith kitloom reading the live database, both sites answer from the same data at the same moment: a difference is kitloom's, never the content's.
The addresses come from the sitemaps of both sites (/wp-sitemap.xml, /sitemap_index.xml, /sitemap.xml — the one WordPress makes and the ones of Rank Math, Yoast and kitloom/seo), a few probes — the home page, a search, a page that does not exist, robots.txt, the feed, /wp-json/ — and your own list. Give it the pages that matter most, the most visited from Analytics or Search Console, paginated archives, old addresses that should redirect:
bash
php compare-sites.php --old=https://example.com --new=http://localhost:8000 --urls=top-pages.txttop-pages.txt holds one address per line, a path (/about/) or a full URL; # starts a comment.
What it checks
| Check | Severity | What it compares |
|---|---|---|
status | error | The status code: 200, 301, 404… |
redirect | error | Where a redirect leads, when both redirect |
robots | error when one side has noindex; otherwise a warning or a note | The robots meta tag |
leftovers | error | [shortcodes] in the text and <!-- wp:… --> block comments that reached kitloom's page undrawn — a plugin without a module |
content | error under 80%, a warning under --threshold (98%) | A record's content: WordPress's REST API against kitloom's page JSON, by the same ID, as the words a visitor reads |
permalink | error | The address of the same record on both sides |
content-paragraphs | warning | Fewer paragraphs on kitloom: the same words run together |
content-links, content-images | warning | Links and images in the record's content |
title, description, canonical | warning; a note where WordPress had none | The head of the page |
list, list-order | warning | On an archive, the blog and the search: the posts kitloom lists must be on WordPress's page, in the same order |
h1, links, images, list-extra | note | The first heading, links to records and images on the whole page — the parts a theme decides |
The layout around the content is not compared: two themes differ by design. When kitloom's theme is a Vue one, its pages are drawn in the browser, so their headings, links and images are not in the HTML at all — the report says how many such pages there were. Those are for the next step.
Reading the report
It writes two files into compare-report/:
report.md— a summary by check, then every address with an error or a warning, worst first, then notes grouped by check;results.json— everything that was read from both sides: statuses, heads, content similarity, lists, the issues. With--save-html, both pages of every address are inhtml/old/andhtml/new/.
md
### `/contact/` — 200 / 200 · page
- **error** `leftovers` In kitloom's content as text: [wpforms]
- **warning** `content` 91.2% of the words match (34 words → 31). First difference: «…we answer within a day. Name Email Message Send» → «…we answer within a day. [wpforms id="12"]»
- **note** `canonical` (none) → /contact/Here a form plugin has no kitloom module: its shortcode reaches the page as text. The fix is a module for that plugin, or the form made again with kitloom/forms and its shortcode put in the page instead.
The exit code is 1 when there are errors, so the run can stand in a script or CI. Fix, run it again, until only what you accept is left. Some differences are known:
| Difference | Why |
|---|---|
/feed/ answers 404 | kitloom has no RSS feeds |
/wp-sitemap.xml answers 404 | kitloom/seo serves /sitemap_index.xml, as Rank Math and Yoast do |
/?cat=3, /?author=2 and other query addresses | kitloom reads ?s=, ?p=, ?page_id=, ?paged= and ?cpage=; ?p= and ?page_id= redirect to the pretty address |
robots: max-image-preview:large only on WordPress | WordPress adds it to every page; a note |
canonical, description only on kitloom | kitloom/seo writes them where WordPress without an SEO plugin did not; a note |
| Old addresses do not redirect | In the read-only look kitloom/redirects is left out; on the copy it is back |
content of a type is not compared | The type is not in WordPress's REST API (show_in_rest) or the record is password-protected — the report says which |
content of a shop's cart, checkout or account page differs | WordPress draws WooCommerce's blocks into the page's content; kitloom's theme draws the cart, the checkout and the account from the shop's own parts. Look at those pages in a browser |
Run php compare-sites.php --help for every option: --limit for a quick first run, --concurrency and --timeout for a slow host, --old-header and --new-header for a site behind basic auth, --alias for a second host name of the same site, --match=slug when kitloom was filled from a WXR file and the IDs differ.
4. An AI agent's pass
The script reads markup and data; what a visitor sees — a gallery that does not open, a menu in the wrong order, a form with a field missing, a price without its currency — needs eyes. An AI agent that can run commands and drive a browser does that pass well: Claude Code with a browser, Cursor or another agent with a Playwright MCP server.
Give it the prompt below with your two addresses filled in. It keeps the agent from writing anything on the live site, makes it group findings by cause rather than by page, and asks for a report you can act on. Run it again after each round of fixes, with the previous MIGRATION-CHECK.md: "check what was fixed and what is still open".
md
You are checking that a WordPress site and kitloom running on its database show
the same site to a visitor.
- WordPress (the live site): https://example.com
- kitloom: http://localhost:8000 — the application is in ./my-site
- compare-sites.php is in the current directory.
- kitloom reads the live database with a read-only user: [yes / no, it runs on a copy]
Rules:
- Read only on the WordPress site. Never submit a form, add to a cart, comment, sign in,
or change a setting there.
- Do not fix anything in this pass. Write down what differs and why you think it does.
- The two themes may differ on purpose. Compare what is on a page and how it behaves,
not where things are placed or how they look.
1. Run `php compare-sites.php --old=https://example.com --new=http://localhost:8000
--save-html --out=compare-report`. Read compare-report/report.md and results.json.
2. Group every error and warning by cause, not by address: one cause behind 300 pages is
one finding. For each, decide what it is: a kitloom bug, a plugin with no kitloom module
(its shortcode or block shows as text), a setting, a theme template that is missing or
draws less, content that is wrong in WordPress too, or one of the known differences at
https://kitloom.dev/docs/migrate-from-wordpress.html#reading-the-report.
To see what kitloom made of an address, request it with `Accept: application/json`:
`kind`, `template` and the parts tell which template drew it and with what data.
3. Choose the pages to look at: the home page; for every post type, the newest record and
the one with the most in it (images, galleries, embeds, tables, shortcodes); every
taxonomy archive and its page 2; the blog page and its page 2; a search with results and
one without; a missing page; every page the main menu and the footer link to; pages with
forms (contact, sign-in, cart, checkout, account); every address with an error.
4. Open each of them on both sites, at 1280 px and at 390 px wide. Take a screenshot of
both. Compare: the text and its paragraphs, the images and their captions, the menus,
widgets and breadcrumbs, the dates, prices and currency, the language, the comments, the
fields of every form, the pagination. Note what is missing, extra or different.
5. Try what can be tried without writing: open the menus and the mobile menu, follow a few
links from each menu and check they lead to the same addresses, search from the search
box, page through an archive, open a gallery, play an embedded video, filter a shop list.
6. Check what is not a page: robots.txt; whether both sitemaps list the same addresses; the
favicon; an image address under /wp-content/uploads/.
7. Only when kitloom runs on a copy of the database, never on the live one: check that old
addresses redirect as in WordPress (Redirects → Redirections in the admin); sign in to
the admin as an administrator and as an editor; open and save a post and a page without
changes and check the page on the site is the same; send the contact form (mail goes to
the log: MAIL_MAILER=log); if it is a shop, place an order with the payment provider's
test mode.
8. Write MIGRATION-CHECK.md:
- a summary: ready to switch, or what blocks it;
- a table of findings: number, what differs, where (example addresses and how many),
severity (blocks the switch / fix soon after / accept), the cause, the fix you
suggest (a module, a theme template, a setting, a content change);
- the screenshots of both sites next to each finding;
- the list of pages you looked at.5. A copy for everything that writes
The admin, forms, the cart and redirects need kitloom's own tables, and those are made by php artisan migrate. Run it on a copy: while WordPress is still the site, its database is not the place to try things out — whatever you then do in kitloom's admin would be written there too.
What migrate changes
It adds kitloom's tables beside WordPress's — sessions, the cache, the queue, roles, the modules' own — and the tables of a plugin a module reads (Rank Math's, WooCommerce's) only where the site does not have them. WordPress's tables and rows stay as they were: the roles WordPress gave are taken over, not rewritten. An active theme with a field group of its own (Bureau, Pantry, Game Reviews) adds that group, as ACF would.
Take the copy with the read-only user. --single-transaction reads a consistent snapshot without locking WordPress's tables, --no-tablespaces needs no PROCESS privilege:
bash
mysqldump -h db.example.com -u kitloom_read -p \
--single-transaction --no-tablespaces wordpress > wordpress.sql(mariadb-dump on MariaDB; on a server with GTIDs add --set-gtid-purged=OFF; WordPress itself has no views or triggers — a plugin that adds them needs SHOW VIEW and TRIGGER too.) A backup from the host's panel or a phpMyAdmin export is just as good. Load it into a database of your own, on your machine or a staging server:
bash
mysql -u root -p -e 'CREATE DATABASE wordpress_copy'
mysql -u root -p wordpress_copy < wordpress.sqlThen turn the read-only look into a full application: point .env at the copy with a user that may write, put sessions, the cache and the queue back (SESSION_DRIVER=database, CACHE_STORE=database, QUEUE_CONNECTION=database, remove PULSE_ENABLED), empty the dont-discover list again, and:
bash
php artisan package:discover
php artisan migrate
composer run devmigrate adds kitloom's tables; WordPress's keep their structure. Now sign in to the admin at /admin (/nova with Nova) with your WordPress email or login and your WordPress password — the password hashes are read as WordPress wrote them. Posts, pages, media, menus, users, Settings → Reading and Permalinks show what WordPress had, and whatever you change stays on the copy.
6. Rehearse the switch
On the copy, or on a staging server with a fresh one, go through what the switch will do:
Uploads. Copy
wp-content/uploads/into the media disk, keeping theY/mfolders, and removeMEDIA_UPLOADS_URL:bashrsync -a [email protected]:/var/www/html/wp-content/uploads/ storage/app/public/ php artisan storage:linkOld links to
/wp-content/uploads/…in post content get a 301 to the new address (kitloom/media).Plugin settings that are imported once. Analytics, tag manager, ad pixels and cookie consent have an Import button on their settings page and an artisan command (
analytics:import,tag-manager:import,ad-pixels:import,cookie-consent:import). Snippets from Insert Headers and Footers or WPCode are pasted intokitloom/header-footeragain; users with two-factor sign-in set it up again.The admin. Edit and save a post, a page, a menu; upload an image; sign in as each role the site has.
What runs in the background. Mail settings (
MAIL_*), a queue worker (php artisan queue:work) and the scheduler (* * * * * php artisan schedule:run) — emails of forms, shop orders and bookings wait in the queue without a worker.Time it. How long the final copy,
migrateand the uploads sync take is how long the switch keeps content frozen.Compare again with
--oldthe live site and--newthe copy, and let the agent do its pass with step 7 of the prompt. A post published after the copy was taken answers 404 on kitloom: the copy is older, not wrong.
7. Switch
- Freeze content. Nobody edits in WordPress from now on. A shop takes no orders for the length of the switch, or accepts that orders placed meanwhile stay in WordPress.
- Back up the live database and keep it.
- Move the database. Load the final copy into kitloom's database server, or point kitloom at the live database. Then
php artisan migrate --force. - Sync uploads once more —
rsynccopies only what changed. - Switch the domain to kitloom: the web server or DNS. Set
APP_URL=https://example.com, thenphp artisan optimize. - Compare a last time. Keep WordPress on the backup at another address for a few days — behind basic auth,
--old-header="Authorization: Basic …"— and run the comparison against the live kitloom. - Watch the 404 log (Redirects → 404 Monitor in the admin), Search Console's coverage and the server's error log during the first days.
- Drop the read-only user once nobody needs it:
DROP USER 'kitloom_read'@'%';
The move is one way: kitloom writes new arrays as JSON, which WordPress does not read. Going back means restoring the backup into WordPress. Content written in kitloom after the switch travels as a WXR file — Import & Export → Export in Filament, the Export action on Import & Export → Import / Export in Nova (kitloom/import-export), the format of WordPress's own importer; try the way back during the rehearsal if it matters to you.
Next steps
- WordPress Compatibility — what is read from WordPress and its plugins.
- Installation — the WordPress database settings.
- Writing a Theme — the site's own look.
- AI Agents — an agent that edits the site once it runs on kitloom.