Back to Blog
Craft CMS Migration Craft 5

Migrating from Craft 4 to Craft 5: What I Learned the Hard Way

· 14 min read

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:

Major Changes in Craft 5 Entry Types Refactor Entry types are now global, not tied to sections. Sections reference shared entry types. Impact: Config files, templates, custom module queries Content Model Changes Matrix is now just entries. Custom field layouts per entry type, not per section. Impact: Matrix field Twig code, field layout config PHP & Platform Requires PHP 8.2+. Updated Yii 2.0.49+. Deprecated methods removed. Impact: Custom plugins/modules, server requirements Twig Changes Twig 3.x enforced. Some filters/functions renamed or removed. Impact: Every template file needs a review Plugin Ecosystem Plugins need Craft 5 compatible versions. Most major ones are ready. Impact: Audit your plugin list before starting Project Config YAML format changes for entry types and sections. Migration handles most of it. Impact: Review diffs carefully after migration runs

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:

  1. 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.
  2. Check your PHP version. Craft 5 requires PHP 8.2 or higher. If your server is on 8.1 or lower, handle that first.
  3. Back up everything. Database and files. I usually take a full database dump and keep it somewhere safe, separate from any automated backups.
  4. Run Craft's deprecation warnings. Turn on devMode in 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.
  5. Set up a staging environment. Never do this on production first. Clone your site, do the migration there, test everything, then deploy.
Seriously, don't skip the plugin audit. I once started a migration only to discover halfway through that a plugin the client relied on heavily hadn't been updated yet. Had to roll back and wait three weeks. Not fun for anyone.

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.

composer.json
{
  "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:

Terminal
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.

If you're dealing with a lot of plugins, I find it easier to update Craft CMS first by itself (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:

Terminal
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.

Terminal
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:

Craft 4 - config/project/sections/blog.yaml
name: Blog
handle: blog
type: channel
entryTypes:
  post:
    name: Post
    handle: post
    fieldLayout:
      tabs:
        - name: Content
          fields:
            - body
            - featuredImage

To something like this:

Craft 5 - config/project/sections/blog.yaml
name: Blog
handle: blog
type: channel
entryTypes:
  - post
Craft 5 - config/project/entryTypes/post.yaml
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 Twig
{# 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 Twig
{# 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:

  • block becomes an entry (I'd rename the loop variable for clarity)
  • block.type becomes entry.type.handle in switch statements
  • Field access stays the same since entries and blocks both use the same field value syntax
You don't have to rename your loop variables from 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:

Before (Craft 4)
{# Remove a specific entry from a collection #}
{% set otherEntries = entries|without(currentEntry) %}
After (Craft 5)
{# 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:

Correct usage
{# 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:

Before (Craft 4)
{% includeCssFile "/assets/special-page.css" %}
{% includeJsFile "/assets/special-page.js" %}
After (Craft 5)
{% 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.

Before (Craft 4 module)
class MyModule extends Module
{
    public function init()
    {
        parent::init();
        // ...
    }
}
After (Craft 5 module)
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:

Craft 5 event handler
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
        }
    }
);
Run 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:

  1. Control panel check. Log in and click through every section, entry type, and settings page. Look for errors or things that look wrong.
  2. 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.
  3. Form submissions. If the site has contact forms, payment flows, or any kind of user input, test those specifically.
  4. Content editing. Create a new entry, edit an existing one, add Matrix content, upload an image. Make sure the content editing experience still works.
  5. 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:

Terminal
# 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:

Craft 5 eager loading
{# 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.