Why Every Custom Post Type I Ship Now Comes With a Migration File
Two years into a client project, I renamed a post meta key from `_price_override` to `_manual_price_override` to make the intent clearer, pushed it, and quietly broke every product on the site that had ever used the old key, because I'd changed the code but never touched the 40,000 rows of existing data still keyed to the old name. Nothing in the deploy pipeline caught it, because nothing was tracking that the data shape and the code had drifted apart. That's the day I stopped treating the WordPress database as something I edit by hand and started treating it like any other piece of infrastructure with a version.
Now every custom post type, taxonomy, or meta-key change ships with a migration file: a small PHP class with an `up()` method and a version number, registered through a WP-CLI command I wrote once and reuse on every project. On deploy, the pipeline runs `wp custom-migrate run`, which checks a `blog_schema_version` row in `wp_options`, applies any migrations newer than the stored version in order, and bumps the number when it's done. The old meta-key rename becomes a migration that reads every row with the legacy key and rewrites it to the new one in a single pass, instead of a manual `$wpdb` query I run once from memory and never think about again.
This matters more than it sounds like it should, because WordPress makes it dangerously easy to skip this discipline. There's no schema file forcing you to declare what a custom post type's fields are supposed to look like. You just start calling `update_post_meta` with a new key and the "schema" is whatever's currently in the database, discoverable only by grepping the codebase or querying production directly. A migration file makes that implicit schema explicit and, critically, makes it something staging can run before production ever sees it.
The GitHub Actions side of this is intentionally boring: the deploy step runs migrations against staging first, the deploy only proceeds if that exits clean, and the exact same migration then runs against production seconds later with the code that depends on it already live. No developer is SSHing into a server running an ad hoc SQL query at 11pm, which used to be exactly how these things happened.
I still keep a rollback method on every migration class, even though I've only used one twice in eighteen months. The value isn't that I need it often. It's that writing the rollback forces me to think through what the migration actually does to production data before I run it there, instead of finding out the hard way.