Migrating from Craft 4 to Craft 5: What I Learned the Hard Way
I've migrated about a dozen Craft 4 sites to Craft 5 at this point. Some went smoothly. A couple were painful. The difference almost always came down to preparation and knowing where the surprises hide.
This post is the guide I wish I had before the first migration. I'm going to cover what actually changed, the step-by-step process I follow, and the specific things that tripped me up so they don't trip you up too.
What Actually Changed in Craft 5
Before jumping into the migration steps, it helps to understand the big picture. Craft 5 isn't a ground-up rewrite. It's more like a major spring cleaning. Most of the changes fall into a few categories:
The biggest conceptual shift is the entry types change. In Craft 4, entry types belonged to sections. In Craft 5, entry types are standalone objects that sections reference. It sounds small, but it changes how you think about content modeling and it affects a lot of config and template code.
Before You Start: The Pre-Migration Checklist
Don't just run the updater and hope for the best. I do these things on every migration before I touch any code:
- Audit your plugins. Go through every installed plugin and check if a Craft 5 compatible version exists. The Craft Plugin Store shows compatibility info. If a critical plugin doesn't support Craft 5 yet, you might need to wait or find an alternative.
- Check your PHP version. Craft 5 requires PHP 8.2 or higher. If your server is on 8.1 or lower, handle that first.
- Back up everything. Database and files. I usually take a full database dump and keep it somewhere safe, separate from any automated backups.
- Run Craft's deprecation warnings. Turn on
devModein Craft 4 and browse your site. Check the deprecation log in the control panel. Fix as many warnings as you can before upgrading. This saves a ton of headaches. - Set up a staging environment. Never do this on production first. Clone your site, do the migration there, test everything, then deploy.
Step 1: Update composer.json
The migration starts in your composer.json. You need to update the Craft CMS requirement and all your plugins to their Craft 5 compatible versions.
{
"require": {
"craftcms/cms": "^5.0.0",
"craftcms/commerce": "^5.0.0",
"nystudio107/craft-seomatic": "^5.0.0",
"nystudio107/craft-vite": "^5.0.0",
"verbb/navigation": "^3.0.0"
}
}
Then run the update:
composer update
This will probably take a minute. Composer needs to resolve all the new version constraints together. If it fails with dependency conflicts, read the error carefully. It's usually one plugin that's holding things back.
composer require craftcms/cms:^5.0.0), then update plugins one at a time. It makes it much clearer which plugin is causing issues if something goes wrong.
Step 2: Run the Migration
After Composer finishes, run Craft's migration command:
php craft up
This is where the magic happens. Craft will run all the necessary database migrations to restructure your content for the new entry type system. On a typical site, this takes anywhere from a few seconds to a couple of minutes depending on how much content you have.
Watch the output. If any migration fails, don't panic. Read the error, note what it says, and check the Craft CMS GitHub issues. Chances are someone else hit the same thing.
Step 3: Review Your Project Config
After the migration runs, your project config YAML files will have changed significantly. The entry type restructuring generates a lot of diffs. Take the time to actually look through them.
git diff config/project/
You'll see that entry types now live in their own config files rather than being nested inside section configs. The structure goes from something like this:
name: Blog
handle: blog
type: channel
entryTypes:
post:
name: Post
handle: post
fieldLayout:
tabs:
- name: Content
fields:
- body
- featuredImage
To something like this:
name: Blog
handle: blog
type: channel
entryTypes:
- post
name: Post
handle: post
fieldLayout:
tabs:
- name: Content
elements:
- type: craft\fieldlayoutelements\CustomField
handle: body
- type: craft\fieldlayoutelements\CustomField
handle: featuredImage
The migration handles all of this automatically, but I always review the diffs to make sure nothing looks off. Especially if you had entry types with the same handle in different sections, since those now need to be unique globally.
Step 4: Update Your Twig Templates
This is where you'll spend most of your time. Here are the Twig changes that come up on almost every migration:
Matrix Blocks are Now Entries
This is the big one. In Craft 4, you'd loop through Matrix blocks like this:
{# Craft 4 #}
{% for block in entry.contentBlocks.all() %}
{% switch block.type %}
{% case "richText" %}
<div class="prose">{{ block.body }}</div>
{% case "image" %}
<figure>
{% set img = block.image.one() %}
{% if img %}
<img src="{{ img.url }}" alt="{{ img.title }}">
{% endif %}
</figure>
{% case "quote" %}
<blockquote>{{ block.quoteText }}</blockquote>
{% endswitch %}
{% endfor %}
In Craft 5, Matrix fields contain entries, not blocks. The template code is very similar, but the variable names and methods change slightly:
{# Craft 5 #}
{% for entry in entry.contentBlocks.all() %}
{% switch entry.type.handle %}
{% case "richText" %}
<div class="prose">{{ entry.body }}</div>
{% case "image" %}
<figure>
{% set img = entry.image.one() %}
{% if img %}
<img src="{{ img.url }}" alt="{{ img.title }}">
{% endif %}
</figure>
{% case "quote" %}
<blockquote>{{ entry.quoteText }}</blockquote>
{% endswitch %}
{% endfor %}
The key differences:
blockbecomes an entry (I'd rename the loop variable for clarity)block.typebecomesentry.type.handlein switch statements- Field access stays the same since entries and blocks both use the same field value syntax
block to entry. The old variable name still works fine since it's just a Twig loop variable. But I'd recommend renaming them over time for clarity, especially if someone else will work on the code later.
The |without Filter
If you used the |without filter, it's been removed in Craft 5. Replace it with Twig's built-in |filter:
{# Remove a specific entry from a collection #}
{% set otherEntries = entries|without(currentEntry) %}
{# Filter out the current entry by ID #}
{% set otherEntries = entries|filter(e => e.id != currentEntry.id) %}
Element Queries: .ids() and .count()
In Craft 4, calling .ids() on an element query returned an array of IDs. That still works, but some of the shorthand methods have been tightened up. If you were chaining .ids() after .all(), stop doing that. Call .ids() directly on the query:
{# Right: call .ids() on the query #}
{% set entryIds = craft.entries.section('blog').ids() %}
{# Wrong: don't chain after .all() #}
{% set entryIds = craft.entries.section('blog').all().ids() %}
Template Loading Changes
If you used {% includeCssFile %} or {% includeJsFile %}, those are gone. Use {% css %} and {% js %} tags with the with keyword instead:
{% includeCssFile "/assets/special-page.css" %}
{% includeJsFile "/assets/special-page.js" %}
{% css "/assets/special-page.css" %}
{% js "/assets/special-page.js" %}
Step 5: Update Custom Modules and Plugins
If you wrote any custom modules (and I usually have at least one on every project), you'll need to update them for Craft 5. The biggest changes:
Type Declarations
Craft 5 requires PHP 8.2+, which means you should be using proper type declarations everywhere. Craft's own codebase has added return types and parameter types to many methods, and if your module overrides any of those, your types need to match.
class MyModule extends Module
{
public function init()
{
parent::init();
// ...
}
}
class MyModule extends Module
{
public function init(): void
{
parent::init();
// ...
}
}
Event Handlers
The event system works the same way, but some event classes have moved or been renamed. Check the Craft 5 changelog for the full list. The most common one I run into is the element events:
use craft\elements\Entry;
use craft\events\ModelEvent;
use yii\base\Event;
Event::on(
Entry::class,
Entry::EVENT_BEFORE_SAVE,
function (ModelEvent $event) {
/** @var Entry $entry */
$entry = $event->sender;
if ($entry->section->handle === 'blog') {
// Do something before saving blog entries
}
}
);
php craft project-config/diff after making your changes to see exactly what project config changes your code generates. It's a great sanity check before committing.
Step 6: Test Everything
I have a testing routine I follow on every migration. It's not fancy, but it catches things:
- Control panel check. Log in and click through every section, entry type, and settings page. Look for errors or things that look wrong.
- Front-end page check. Hit every unique template on the site. If the site has 200 blog posts that all use the same template, I check a few of them, but I make sure I hit every distinct template at least once.
- Form submissions. If the site has contact forms, payment flows, or any kind of user input, test those specifically.
- Content editing. Create a new entry, edit an existing one, add Matrix content, upload an image. Make sure the content editing experience still works.
- Console commands. If you have any custom console commands or cron jobs that touch Craft, run them manually.
Common Gotchas
Duplicate Entry Type Handles
In Craft 4, two different sections could each have an entry type called "default." In Craft 5, entry types are global, so handles must be unique. The migration will usually append the section handle to disambiguate (like default_blog and default_news), but this can break template code that references the old handle.
Grep your templates for entry type handles after migration:
# Find all entry type handle references in your templates
grep -r "type.handle" templates/ --include="*.twig"
grep -r '\.type ==' templates/ --include="*.twig"
Plugin Settings Reset
Some plugins restructure their settings during the Craft 5 migration. I've seen SEOmatic and other plugins reset certain settings to defaults. After migration, go through each plugin's settings page and verify everything looks right. Compare against your Craft 4 settings if you're not sure.
Eager Loading Syntax
If you're using eager loading with Matrix fields (and you should be for performance), the syntax changes slightly since Matrix blocks are now entries:
{# Eager load Matrix entries and their nested relations #}
{% set entries = craft.entries
.section('blog')
.with([
'contentBlocks',
'contentBlocks.image',
])
.all() %}
The good news is the eager loading syntax itself is basically the same. But if you had any custom eager loading maps defined in PHP, double check that they still reference the right classes.
My Migration Timeline
For a typical Craft site with 5-10 plugins and a few custom templates, here's roughly how I break down the work:
- Pre-migration audit and prep: Half a day. Plugin compatibility check, deprecation cleanup, staging setup.
- Composer update and running migrations: 30 minutes to an hour, depending on how smoothly Composer resolves things.
- Template updates: 1-3 hours depending on how many templates reference Matrix fields and entry types.
- Custom module/plugin updates: Varies wildly. Could be 30 minutes, could be a full day if you have complex custom code.
- Testing: 2-4 hours of thorough testing.
For a simple site, you can sometimes do the whole thing in a day. For a complex site with Commerce, lots of custom modules, and a big template set, plan for 2-3 days.
The migration from Craft 4 to 5 is one of the smoother major version upgrades I've done in any CMS. The entry type refactor is the biggest conceptual change, and once you wrap your head around that, everything else falls into place. Just take it step by step, test thoroughly, and don't skip the prep work.
If you're staring at a migration and feeling overwhelmed, or if you've got a complex site with custom plugins that need updating, I'm happy to help. This is exactly the kind of work I do.