Blog

EE Migrations Done Well

Title slide: EE Migrations Done Well, by Rowan Sentesy

Title slide from the EE Conf 2026 talk.

I had the pleasure of representing Hop Studios at the 2026 ExpressionEngine Conference in Toronto. The session I prepared and delivered was about ExpressionEngine migrations, and how to use the core EE tools and AI to plan, build and deploy schema changes. This blog post is for ExpressionEngine developers who want to improve how they ship site changes. The methodology, AI skill and demo repository are all provided for your own use. In the demo I used Docker for local containerized sites, and Cursor as my IDE of choice. The migration methodology doesn’t require either, and can be adapted to other IDEs or local development workflows.

In this post, I’ll follow the arc of the talk and share links to the tools, the slides, and the conference video once it’s posted.

The database half is still done by hand

If you build ExpressionEngine sites, you know the split. Your templates and code live in Git, but your channels, fields and settings live in the database, and most features touch both. The code half gets versioned, reviewed and deployed. The database half usually gets done by hand at the end of a launch: a few minutes of clicking in the control panel, or a bit of SQL pasted into production. It’s easy to get wrong, and easy to do slightly differently on each environment.

A migration fixes that. It packages those database changes as code, so they ship with the feature and run the same way everywhere. I didn’t write migrations for site work for years, because writing one by hand was slower than clicking. AI changed that: now I review a short plan, the agent writes the migration, and I run it. The skill I use is free, and the install instructions are at the end of this post.

Why I skipped migrations for years

Add-on developers have always used migrations, and that always made sense to me: a schema change ships with a new version of the add-on and runs on every install. A one-off field on a client site felt different. Hand-writing a throwaway PHP class seemed slower than clicking, and I’d rather spend that energy on code that runs more than once.

What changed is that the class stopped being the slow part. The work now starts with a plain-text plan: field names, labels, and what I’m not building. If the plan is wrong, I fix it before any PHP exists. Once it’s right, the agent writes the migration, and from there it’s deterministic. Nobody mis-pastes a value on production, and I decide when the code ships and when the migration runs.

That’s when it clicked for me: the clicking had become the slow method. And it works the same way whether the change is one field, eight fields, a content seed, or a repair months later.

What a migration is

A migration is a PHP class with two methods: up(), which applies the changes, and down(), which reverses them. It lives in system/user/database/migrations/. You test it locally, commit it alongside the templates and code for the feature, and then run it in each environment, where it replays exactly the same way. ExpressionEngine already ships the command: php eecli.php migrate, run from system/ee.

Here is the whole file for the simplest case, a Summary textarea added to an existing field group. This is the shape the skill produces for the first demo below.

<?php

use ExpressionEngine\Service\Migration\Migration;

class AddBlogSummaryField extends Migration
{
    public function up()
    {
        if (ee('Model')->get('ChannelField')->filter('field_name', 'blog_summary')->count()) {
            return;
        }

        $field = ee('Model')->make('ChannelField');
        $field->site_id = 1;
        $field->field_name = 'blog_summary';
        $field->field_label = 'Summary';
        $field->field_type = 'textarea';
        $field->field_ta_rows = 3;
        $field->field_order = 1;
        $field->ChannelFieldGroups = ee('Model')->get('ChannelFieldGroup')
            ->filter('short_name', 'field_group_blog')
            ->all();
        $field->save();
    }

    public function down()
    {
        ee('Model')->get('ChannelField')->filter('field_name', 'blog_summary')->delete();
    }
}

up() checks whether the field already exists before creating it, and down() removes it. Most site migrations are longer than this, but they all follow the same shape.

Two flags are worth knowing. --core runs the migrations in your site’s user/database/migrations folder; despite the name, it has nothing to do with upgrading ExpressionEngine itself. --all runs those, then each add-on’s migrations. When up() succeeds, ExpressionEngine records the filename in the exp_migrations table, along with a group number shared by everything that ran in the same command. That file won’t run again on that database.

What belongs in a migration

I use migrations for channels, fields, settings, and any content I’m seeding as part of a feature: anything that has to land on every environment in the same deploy as the templates that depend on it. I leave out anything with no reason to be in git, and I leave out entries that editors create on the live site. Adding an entry is content work, not a schema change.

How I build, test and deploy a migration

Here’s the workflow I use on Hop projects, from plan to production.

  1. Write the spec. Prompt the agent with the ee-migrations skill to draft a work spec.
  2. Read it. Check the field names, the labels, and the list of what you’re not building. This is the cheapest place to catch a mistake.
  3. Build the migration. Tell the agent to write the file. For a small change you can do steps 1 and 3 in a single prompt, as in the first demo below.
  4. Iterate on one file until it ships. If you find a problem before the migration has run anywhere shared, don’t let the agent add a second file to fix the first. Reset your local database from the dump you already have, edit the original file, and run it again.
  5. Deploy with the feature, then run it. The migration goes out in the same deploy as the templates that depend on it.
  6. Record what ran where. Each database’s exp_migrations table only knows about itself. For a small change, a note in the work spec is enough. For a long project, keep a log with each filename and the date it ran in each environment. That log is what tells you what has reached local, staging and production.

Three things to know

Three ideas shape how the skill writes migrations, and how you should treat them once they exist.

Idempotent

An idempotent migration gives the same result whether it runs once or twice. That might sound unnecessary, since a migration that finished is already recorded in exp_migrations and won’t run again. The case it covers is the run that dies halfway: when you start it again, the first half is already there. So every migration looks things up by name, short name, or url_title before creating them, and never relies on hardcoded IDs (“magic numbers”) that differ from one database to the next.

Greenfield

Greenfield means new ground: a migration that hasn’t run on any shared environment yet. Until then, you can keep editing the same file. Once it has run on staging or production, it’s history, and any further change, including a repair or dropping a field, goes in a new file.

down() is not undo

php eecli.php migrate:rollback runs down() for the most recent group. down() can remove what up() added, but it can’t bring back anything up() deleted, and if it fails halfway, the first half of the rollback stays done. An empty down() is worse than it looks: rollback removes the ledger row without changing anything, so the next migrate runs up() again. I don’t roll back on production. Instead, I take a fresh backup right before migrating there, and restore it if something goes wrong.

The prompts from the talk

These are the prompts I pasted during the live demo, verbatim. They assume the demo site: a stock ExpressionEngine 7 site with a Blog channel, a field group named field_group_blog, and an upload directory named Common. If you’re trying them on your own site, swap in your own names. In the seed prompt, use the member who should own the seeded entries (on the demo, member 1 is the admin).

Install the skill first (instructions at the end of this post). Start a new chat for each prompt so the agent begins with a clean context, and begin each one with /ee-migrations so it reads the skill before doing anything else.

In the video you’ll see me type ./ee-cli. That’s a small Hop wrapper that runs the same command in Docker locally and over SSH on a server. It isn’t part of ExpressionEngine, and you don’t need it: the skill calls php eecli.php unless your project already has a wrapper.

One field

The first demo does the spec and the migration in a single pass, which is fine for a change this small. It adds a Summary field to the Blog channel for the blog listing. When the agent finishes, run php eecli.php migrate --core and reload an entry in the control panel to see the new field.

/ee-migrations

Blog channel. Editors need a short summary they will type for the blog listing.
field_name blog_summary, label Summary, field group field_group_blog.

Save the new work spec in the docs/work-specs folder
Build the migration file in the same pass

Eight fields

This is where clicking stops making sense. By hand, it’s create, paste, create, paste, check it on production, then pull the database back down. I’m not clicking these.

This prompt asks for the spec only. Read the eight field names in the spec, then send the follow-up in the same chat.

/ee-migrations

New channel for a reading list. Channel name books, title Books. Field group field_group_books, assigned to books. The entry title is the book title.

Eight fields, in this order:
- book_author, Author, text
- book_year, Year, number, integer, no min or max
- book_cover, Cover, file, existing upload directory named Common
- book_summary, Summary, textarea, 3 rows
- book_progress, Progress, select: Want, Reading, Read. Do not name this field status.
- book_link, Link, url
- book_related, Related books, relationship to the books channel only
- book_favorite, Favorite, toggle, default off

Save the new work spec in the docs/work-specs folder
Build the migration.

Two details in this prompt are deliberate. The select field is named Progress rather than Status, because every entry already has a built-in status, and a second one invites confusion. And the Year field is a test of the skill: a number field created in code without its min and max settings throws a warning on the publish form, so an integer with no limits should still save cleanly.

Seed the books

The last demo seeds content: five books on the new channel, plus a blog post announcing them. Start a new chat. The channel and fields already exist from the previous step, and the five cover images are already in the Common upload directory, named book-<url_title>.jpg, so the prompt tells the agent not to fetch any images. As before, ask for the spec, read it, then send the follow-up.

Seeded entries need an entry_date, edit_date, and ip_address, which the control panel normally fills in for you. A blank IP address breaks Clone Entry, so the skill sets it to 127.0.0.1.

/ee-migrations

Seed five books on the existing books channel. The channel and fields already exist. Do not create schema.

Photo order, top of the stack first. Entry title is the book title. Progress is Read for all five. Entry status is open. Leave year, link, related books, and favorite empty. Do not invent a year. One short summary each, as written below.

- Tales of Power. Author Carlos Castaneda. url_title tales-of-power. Summary: Last of Castaneda's early don Juan books.
- Nineteen Eighty-Four. Author George Orwell. url_title nineteen-eighty-four. Summary: Novel of surveillance, Newspeak, and the Party.
- All That the Rain Promises, and More. Author David Arora. url_title all-that-the-rain-promises-and-more. Summary: Pocket field guide to western mushrooms.
- Iron John. Author Robert Bly. url_title iron-john. Summary: Bly's book on the Grimm tale and men's initiation.
- The Masculine in Relationship. Author GS Youngblood. url_title the-masculine-in-relationship. Summary: On masculine presence in intimate relationship.

The five cover images are already in the Common upload directory, named book-<url_title>.jpg. Do not fetch or add images. Sync only those five into the File Manager and set book_cover.

Then one blog post on the existing blog channel. Title: Five new books on the reading list. url_title: five-new-books-on-the-reading-list. blog_summary: Five books added to the new Books channel. blog_content is a short intro, then the five books in photo order, title and author each. No image, no categories.

author_id is 1. On this site that member is the admin.

Save the new work spec in the docs/work-specs folder
Build the migration.

After that migration runs, the books and the blog post are on the site. The only reason to open the control panel is to check the result.

A real project: Bridging Differences

The demo is small on purpose, so here’s what the method looks like at scale. Bridging Differences is a new part of the Greater Good in Action site. Its schema shipped through this method in waves, each with seeded content, so editors could start from real entries and make them their own. By July, editors were working on that content in production; by September, the front end was in place around it.

I can’t imagine clicking that project into production. Across that many waves, the log was what told us which migrations had reached local, staging and production, and every repair along the way was a new file rather than an edit to one that had already run.

What’s still your job

The skill carries a lot of the work, from drafting the plan to writing the migration. A few things still need you:

  • Own the plan. The skill drafts the work spec from your prompt, and it knows which questions to ask about the schema. What it can’t know is how your editors work or what this feature is really for. Read the spec closely and correct it before you build, because the migration will only be as right as the plan.
  • Make sure eecli.php actually runs on the servers you deploy to. A successful run on your laptop doesn’t prove that.
  • Test against real data. Before production, re-import a recent production dump locally and run the migration against it.

Get the skill

The skill is free to use, released under the Hop Studios commercial license. It isn’t open source, so please don’t redistribute it.

To install it in Cursor:

git clone https://gitlab.com/hop-studios-public/ee-migrations.git ~/.cursor/skills/ee-migrations

For Claude Code, clone the same repo into ~/.claude/skills/ee-migrations.

Then start a new chat and type /ee-migrations. To update later, run git pull in that folder.

If launching on your site still means a checklist of clicks, get in touch. We’d be happy to take a look.

Have a Project for Us?

Request a Proposal