Custom PHP migrations
When a migration needs logic the screen cannot express, write it in PHP with
cafe_import_object(). You never touch CAFE's storage
yourself: CAFE's API does the saving, validation and caching.
Full example: properties, agents and team members
Old tables:
wp_old_agents (id, name, email)
wp_old_properties (id, title, description, price, agent_id, featured_image, status)
wp_old_property_team (row_id, property_id, name, role, photo)
Target: post types agent and property; CAFE fields price (Number, decimal),
agent (Post Object), hero_image (Image), status (Select: available, sold) and
team_members (Collection: name, role, photo).
Save as a small plugin (for example wp-content/plugins/my-migration/my-migration.php)
and run it with wp my-migration run:
<?php
/**
* Plugin Name: My migration
*/
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
return;
}
WP_CLI::add_command( 'my-migration run', function ( $args, $assoc_args ) {
global $wpdb;
$dry_run = ! empty( $assoc_args['dry-run'] );
$options = [ 'dry_run' => $dry_run, 'default_status' => 'publish' ];
// 1. Agents first: properties link to them.
foreach ( $wpdb->get_results( "SELECT id, name FROM {$wpdb->prefix}old_agents ORDER BY id" ) as $agent ) {
cafe_import_object(
[
'post_type' => 'agent',
'source' => 'legacy-agents',
'source_id' => $agent->id,
'wp' => [ 'post_title' => $agent->name ],
],
$options
);
}
// 2. Properties, 200 at a time: memory stays flat.
$batch_size = 200;
$offset = 0;
do {
$properties = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}old_properties ORDER BY id LIMIT %d OFFSET %d", $batch_size, $offset )
);
if ( ! $properties ) {
break;
}
// All team rows of this batch in one query.
$ids = wp_list_pluck( $properties, 'id' );
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$team_rows = $wpdb->get_results(
$wpdb->prepare( "SELECT property_id, name, role, photo FROM {$wpdb->prefix}old_property_team WHERE property_id IN ($placeholders) ORDER BY property_id, row_id", ...$ids )
);
$teams = [];
foreach ( $team_rows as $row ) {
$teams[ $row->property_id ][] = [
'name' => $row->name,
'role' => $row->role,
'photo' => (int) $row->photo ?: null, // attachment IDs in this example
];
}
foreach ( $properties as $property ) {
$result = cafe_import_object(
[
'post_type' => 'property',
'source' => 'legacy-properties',
'source_id' => $property->id,
'wp' => [
'post_title' => $property->title,
'post_content' => $property->description,
],
'fields' => [
'price' => (float) str_replace( ',', '.', $property->price ),
'agent' => cafe_imported_object_id( 'legacy-agents', $property->agent_id ) ?: null,
'hero_image' => (int) $property->featured_image ?: null,
'status' => '1' === $property->status ? 'available' : 'sold',
'team_members' => $teams[ $property->id ] ?? [],
],
],
$options
);
if ( 'failed' === $result['status'] ) {
foreach ( $result['problems'] as $problem ) {
WP_CLI::warning( "Property {$property->id}, {$problem['field']}: {$problem['message']}" );
}
}
}
$offset += $batch_size;
cafe()->flush_runtime_cache(); // Free what this batch loaded.
WP_CLI::log( "{$offset} properties done" );
} while ( count( $properties ) === $batch_size );
WP_CLI::success( $dry_run ? 'Dry run finished. Nothing was written.' : 'Migration finished.' );
} );
wp my-migration run --dry-run
wp my-migration run
Running it twice updates the same posts (source + source_id). Each property with
its values and team is written in one transaction: a property with an invalid value is
reported and not written at all.
Smaller building blocks
| Need | Use |
|---|---|
| Save several values of an existing object | cafe_save_values() |
| Check a value first | cafe_check_value() |
| Old ID → new object | cafe_imported_object_id() |
| Settings Page values | cafe_import_object( [ 'object_type' => 'option', 'fields' => [ … ] ] ) or cafe_update_option() |
| Read many objects afterwards without N+1 | cafe_prime_fields() |
Or: a source for the screen
When non-developers should run the migration (with preview, dry run, history and rollback), write a custom source instead: your code only reads records, and CAFE → Migration does the rest.