This spring I migrated four client sites from Craft CMS 4 to Craft CMS 5. Every one of them is now on a supported core, every one kept every word of its content, and every production cutover was boring. This post is the long version of how, including the part where the automated migration silently threw away 571 calendar events and the process that caught it.
Why now
Craft 4 reached end-of-life on April 30, 2026. No more security patches, full stop. And the situation was already worse than “unpatched going forward”: Craft 4 pins Twig to ~3.19, and there are fifteen known Twig CVEs fixed in later Twig releases that you simply cannot get on Craft 4. For months my composer.json files carried an audit.ignore block documenting vulnerabilities I had no way to fix. That is not tech debt, that is a countdown clock.
There is also a bigger shift on the horizon. Craft 6, expected later this year, moves the platform from Yii 2 to Laravel. Pixel & Tonic is shipping a Yii 2 adapter so most Craft 5 plugins keep working, and Craft 5 becomes the official LTS release the moment 6 ships. But Craft upgrades are sequential: there is no path from 4 straight to 6. Sites still on Craft 4 are about to be two majors behind a framework transition. Getting to 5 now was the move.
The plan: phases, snapshots, and an oracle
The largest of the four sites drove the process. It had years of content, Solspace Calendar and Freeform, a custom Mapbox module, GraphQL-powered Vue filters, and a field schema built on plugins that do not exist on Craft 5. I broke the migration into phases, and after every phase I did two things:
- Took a database snapshot, so any phase could be rolled back without restarting the project.
- Stopped for manual testing and sign-off before touching the next phase, especially before anything destructive.
Before phase one, I also built what I ended up calling a content oracle: a full extract of every element’s title and field content from the Craft 4 database. Not a spot-check list. Everything. The entire migration would be judged against it at the end, item by item.
The phases, roughly:
- Core upgrade to Craft 5, plus the mechanical fixes that fall out of it (entry-type handle prefixing, control panel tweaks).
- Super Table to native Matrix. Craft 5’s Matrix-as-nested-entries replaces what Super Table did, so 34 Super Table fields became native Matrix fields, and the
verbb/super-tablebridge plugin was uninstalled and removed entirely. - Redactor to CKEditor. Every rich text field converted via
php craft ckeditor/convert/redactor, then the Redactor stack removed. I also ported the old Redactor editor styles to CKEditor’s config, so editors see real brand typography, link styles, and buttons while they write instead of a generic white box. - Template and integration fixes. Craft 5 renames GraphQL types, so the Vue-based listing filters needed their queries updated. The custom Mapbox field’s GraphQL also needed work. This phase is where a migration stops being a checklist and starts being development.
The part that would have shipped broken: 571 missing events
Here is the reason I bother with the oracle. Craft’s 4-to-5 content migration moves field content into the new element-level JSON storage, and it does this for entries. It does not do it for element types owned by plugins. On this site, that meant every single Solspace Calendar event, all 571 of them, arrived in Craft 5 with a NULL title and NULL content. The entries all migrated perfectly. The events were empty shells.
Nothing errored. Nothing warned. If my verification had been “click around the site for an hour,” the events calendar renders, the URLs resolve, and the titles are just… blank. Easy to miss on a site with hundreds of templates, and catastrophic to discover two weeks after launch.
The oracle diff caught it immediately: 571 titles expected, 0 found. The good news is that Craft 5 leaves the legacy content table in place after migrating, so the source data was still sitting right there. Solspace ships recovery commands for exactly this situation:
php craft calendar/events/fix-titles
php craft calendar/events/fix-contents
php craft clear-caches/all
That last line is not optional. The site served event JSON through Element API, and the cached responses kept returning NULL titles until caches were cleared. Final tally against the oracle: 571 of 571 titles recovered, and 507 of 571 bodies, with the other 64 confirmed genuinely empty in Craft 4 as well. Zero loss.
If you take one thing from this post: a migration that finishes without errors has not told you it worked. Diff the content.
The cutover: migrate locally, push the database
The other structural decision that paid off: none of the migration ran on a server. The cutover for each site was a script that does this:
- Pull a fresh copy of the production database down locally.
- Run the entire phased migration against it locally.
- Run the verification pass.
- Dump the migrated database and restore it on the server.
- Deploy the Craft 5 codebase, where
craft migrate/allis a no-op because the database is already migrated.
Part of this was necessity: ckeditor/convert/redactor is interactive and refuses to run without a TTY, and the bridge plugins needed mid-migration are not in the final composer.json, so the sequence cannot run unattended on a production box anyway. But mostly it is just a better shape for the risk. The slow, failure-prone work happens on my machine with unlimited retries, and production only ever sees a finished, verified database. Assets lived in S3 and are referenced by ID, so the database was the only thing that had to move.
Before production, I ran the full cutover script against staging as a dress rehearsal. It found three real bugs in the script: the CKEditor plugin needed installing before the convert step, a handful of orphaned Redactor field records in old Matrix-block contexts survived project-config/apply and needed explicit cleanup, and a shell quoting issue in the verification queries. All three got fixed and committed, and the production run hit none of them. That is the whole argument for rehearsal in one sentence: the bugs happened, they just happened on staging.
What Craft 5 actually bought
- A supported core with security patches, and those fifteen ignored Twig CVEs finally resolved rather than documented.
- Native nested entries replacing Super Table, which removed a whole plugin dependency from the update treadmill.
- CKEditor with real config-driven editor styling, which editors noticed and liked immediately.
- A clean runway to Craft 6 and the Laravel era, with Craft 5 as the LTS fallback if we want to let 6.0 settle first.
Takeaways
- The automated migration is half the job. It handles entries well. Plugin element types, custom modules, GraphQL consumers, and editor UX are on you.
- Build a content oracle before you start. A full before/after content diff is the only verification that catches silent, uniform data loss. Spot-checks do not.
- Snapshot every phase, test every phase. Rolling back one step is a Tuesday. Rolling back a finished migration is a crisis.
- Migrate locally, ship the database. Production should only ever meet the finished product.
- Rehearse the cutover on staging. My script had three bugs. Yours will have some too. Choose where you find them.
The launch should be the least interesting part of the project. Four times in a row now, it was.
Leave a Reply