Last active
March 11, 2025 15:43
-
-
Save pierrerocket/860a8fb85acffe34e6acf21cbca23ebd to your computer and use it in GitHub Desktop.
WP CLI Command to Find and clean orphaned ACF Postmeta from all Posts. Includes logging.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* Find orphaned ACF field data in post meta. | |
* | |
* Searches for ACF field data in all posts where the field definitions | |
* no longer exist in any field groups. | |
* | |
* ## OPTIONS | |
* | |
* [--dry-run] | |
* : Run without making any changes (just report findings). | |
* | |
* [--log=<filename>] | |
* : Save output to log file. If no filename is given, uses 'acf-orphaned-fields-{timestamp}.log'. | |
* | |
* ## EXAMPLES | |
* | |
* wp acf orphaned-fields | |
* wp acf orphaned-fields --dry-run | |
* wp acf orphaned-fields --log | |
* wp acf orphaned-fields --dry-run --log | |
* wp acf orphaned-fields --log=my-acf-report.log | |
*/ | |
// Add this to a custom plugin or your theme's functions.php | |
if ( defined( 'WP_CLI' ) && WP_CLI ) { | |
WP_CLI::add_command( 'acf orphaned-fields', 'find_orphaned_acf_fields' ); | |
} | |
function find_orphaned_acf_fields( $args, $assoc_args ) { | |
$dry_run = isset( $assoc_args['dry-run'] ); | |
$log_enabled = isset( $assoc_args['log'] ); | |
$log_file = null; | |
$log_contents = ''; | |
// Set up logging if enabled | |
if ($log_enabled) { | |
$log_filename = $assoc_args['log']; | |
// If no filename provided, create one with timestamp | |
if ($log_filename === true) { | |
$timestamp = date('Y-m-d-His'); | |
$log_filename = "acf-orphaned-fields-{$timestamp}.log"; | |
} | |
// Make sure log directory exists | |
$upload_dir = wp_upload_dir(); | |
$log_dir = trailingslashit($upload_dir['basedir']) . 'acf-logs'; | |
if (!file_exists($log_dir)) { | |
// Try to create directory with proper error handling | |
if (!wp_mkdir_p($log_dir)) { | |
WP_CLI::warning("Could not create log directory at: {$log_dir}"); | |
WP_CLI::log("Saving log to uploads base directory instead."); | |
$log_dir = $upload_dir['basedir']; | |
} | |
} | |
// Make sure log directory is writable | |
if (!is_writable($log_dir)) { | |
WP_CLI::warning("Log directory is not writable: {$log_dir}"); | |
WP_CLI::log("Saving log to system temp directory instead."); | |
$log_dir = sys_get_temp_dir(); | |
} | |
$log_file = trailingslashit($log_dir) . $log_filename; | |
// Log header | |
$log_contents .= "==== ACF ORPHANED FIELDS REPORT ====\n"; | |
$log_contents .= "Generated: " . date('Y-m-d H:i:s') . "\n"; | |
$log_contents .= "WordPress Site: " . get_bloginfo('name') . " (" . get_bloginfo('url') . ")\n\n"; | |
WP_CLI::log("Logging enabled. Output will be saved to: {$log_file}"); | |
} | |
// Custom log function that outputs to both CLI and log file if enabled | |
$logger = function($message) use (&$log_contents, $log_enabled) { | |
WP_CLI::log($message); | |
if ($log_enabled) { | |
$log_contents .= $message . "\n"; | |
} | |
}; | |
// Make sure ACF is active | |
if ( !function_exists('acf_get_field_groups') ) { | |
WP_CLI::error( 'Advanced Custom Fields is not active.' ); | |
return; | |
} | |
// Get all ACF field groups | |
$field_groups = acf_get_field_groups(); | |
if ( empty( $field_groups ) ) { | |
WP_CLI::error( 'No ACF field groups found.' ); | |
return; | |
} | |
// Get all existing field keys | |
$existing_field_keys = array(); | |
foreach ( $field_groups as $field_group ) { | |
$fields = acf_get_fields( $field_group ); | |
if ( !empty( $fields ) ) { | |
$existing_field_keys = array_merge( $existing_field_keys, get_all_acf_field_keys( $fields ) ); | |
} | |
} | |
WP_CLI::log( sprintf( 'Found %d valid ACF field keys across all field groups.', count( $existing_field_keys ) ) ); | |
// Get all published posts of any type | |
$all_post_types = get_post_types(['public' => true], 'names'); | |
$args = array( | |
'post_type' => $all_post_types, | |
'posts_per_page' => -1, | |
'post_status' => 'any', | |
); | |
$posts = get_posts($args); | |
WP_CLI::log( sprintf( 'Examining %d posts, pages, and custom post types...', count( $posts ) ) ); | |
$orphaned_count = 0; | |
$posts_with_orphans = 0; | |
// Progress bar for CLI | |
$progress = \WP_CLI\Utils\make_progress_bar( 'Scanning posts', count( $posts ) ); | |
// Loop through each post | |
foreach ( $posts as $post ) { | |
$post_id = $post->ID; | |
$post_has_orphans = false; | |
// Get all post meta for this post | |
$post_meta = get_post_meta($post_id); | |
$acf_fields = []; | |
$orphaned_fields = []; | |
// Identify ACF fields in post meta | |
foreach ( $post_meta as $meta_key => $meta_values ) { | |
// Skip non-ACF fields | |
if ( !is_acf_field_key($meta_key) && !is_acf_field_ref($meta_key) ) { | |
continue; | |
} | |
$meta_value = $meta_values[0]; // get_post_meta returns array of values | |
// The issue is that regular ACF field values don't have "field_" in their meta_key | |
// They have normal names like "color" or "product_description" | |
// Only the reference fields (starting with _) contain the actual field key | |
// If this is a reference field (starts with _) | |
if ( is_acf_field_ref($meta_key) ) { | |
$field_key = $meta_value; // The value of _fieldname contains the actual field key | |
$field_name = substr($meta_key, 1); // Remove leading underscore to get field name | |
// Only process valid field keys | |
if ( strpos($field_key, 'field_') !== 0 ) { | |
continue; | |
} | |
// Get the actual field value | |
$field_value = get_post_meta($post_id, $field_name, true); | |
// Check if this field is orphaned (no longer exists in any field group) | |
if ( !in_array($field_key, $existing_field_keys) ) { | |
// Add to orphaned fields | |
$orphaned_fields[$field_key] = [ | |
'field_key' => $field_key, | |
'field_name' => $field_name, | |
'data_preview' => truncate_meta_value($field_value), | |
]; | |
$orphaned_count++; | |
$post_has_orphans = true; | |
} else { | |
// Add to regular ACF fields | |
$acf_fields[$field_key] = [ | |
'field_key' => $field_key, | |
'field_name' => $field_name, | |
'field_value' => truncate_meta_value($field_value), | |
]; | |
} | |
} | |
} | |
// Output information if post has orphaned fields | |
if ( $post_has_orphans ) { | |
$posts_with_orphans++; | |
$logger( "\n----- Post ID: {$post_id} -----" ); | |
$logger( "Title: " . $post->post_title ); | |
$logger( "Type: " . $post->post_type ); | |
$logger( "Edit URL: " . get_edit_post_link($post_id) ); | |
// Use ACF's API to get the actual fields for this post | |
$acf_fields = []; | |
// Get field groups for this post | |
$field_groups_for_post = acf_get_field_groups(['post_id' => $post_id]); | |
if (!empty($field_groups_for_post)) { | |
$logger( "\nActive ACF Field Groups:" ); | |
foreach ($field_groups_for_post as $group) { | |
$logger( "- {$group['title']}" ); | |
// Get fields for this group | |
$fields = acf_get_fields($group); | |
if (!empty($fields)) { | |
foreach ($fields as $field) { | |
$acf_fields[$field['key']] = [ | |
'field_key' => $field['key'], | |
'field_name' => $field['name'], | |
'field_label' => $field['label'], | |
'field_group' => $group['title'] | |
]; | |
} | |
} | |
} | |
$logger( "\nActive ACF Fields:" ); | |
foreach ( $acf_fields as $field ) { | |
$value = get_field($field['field_name'], $post_id); | |
$preview = truncate_meta_value($value); | |
$logger( "- {$field['field_label']} ({$field['field_key']})" ); | |
$logger( " Group: {$field['field_group']}" ); | |
$logger( " Data: {$preview}" ); | |
} | |
} else { | |
$logger( "\nNo active ACF field groups." ); | |
} | |
if (!empty($orphaned_fields)) { | |
$logger( "\nOrphaned ACF Fields:" ); | |
foreach ( $orphaned_fields as $field ) { | |
$logger( "- {$field['field_name']} ({$field['field_key']})" ); | |
$logger( " Data: {$field['data_preview']}" ); | |
// Try to find what field group this might have belonged to | |
// by looking at the field key pattern | |
$probable_group = "Unknown"; | |
// Some field keys follow patterns that might help identify their group | |
// Loop through existing field groups to see if there's a pattern match | |
foreach ($field_groups as $group) { | |
$group_fields = acf_get_fields($group); | |
if (!empty($group_fields) && !empty($group_fields[0]['key'])) { | |
// Compare first 10 characters of the key which often identify the group | |
$sample_key = substr($group_fields[0]['key'], 0, 10); | |
$orphan_key_start = substr($field['field_key'], 0, 10); | |
if ($sample_key === $orphan_key_start) { | |
$probable_group = $group['title']; | |
break; | |
} | |
} | |
} | |
$logger( " Probable Field Group: {$probable_group}" ); | |
} | |
} | |
} | |
$progress->tick(); | |
} | |
$progress->finish(); | |
if ( $orphaned_count === 0 ) { | |
$logger( 'No orphaned ACF field data found.' ); | |
// Save log if enabled | |
if ($log_enabled) { | |
$log_contents .= "\nNo orphaned ACF field data found."; | |
file_put_contents($log_file, $log_contents); | |
WP_CLI::success("Log saved to: {$log_file}"); | |
} | |
return; | |
} | |
$logger( sprintf( "\nFound %d orphaned ACF field entries across %d posts.", | |
$orphaned_count, $posts_with_orphans ) ); | |
// Save log if enabled | |
if ($log_enabled) { | |
try { | |
// Make sure the directory exists one more time before writing | |
$log_dir = dirname($log_file); | |
if (!file_exists($log_dir)) { | |
mkdir($log_dir, 0755, true); | |
} | |
// Write the file with proper error handling | |
if (file_put_contents($log_file, $log_contents)) { | |
WP_CLI::success("Log saved to: {$log_file}"); | |
} else { | |
// Try system temp directory as a last resort | |
$temp_log_file = sys_get_temp_dir() . '/' . basename($log_file); | |
if (file_put_contents($temp_log_file, $log_contents)) { | |
WP_CLI::success("Log saved to temporary location: {$temp_log_file}"); | |
} else { | |
WP_CLI::error("Failed to save log file."); | |
} | |
} | |
} catch (Exception $e) { | |
WP_CLI::error("Exception while saving log: " . $e->getMessage()); | |
} | |
} | |
// Offer to delete if not in dry run mode | |
if ( !$dry_run && $orphaned_count > 0 ) { | |
WP_CLI::confirm( "Would you like to delete all orphaned ACF field data found?" ); | |
// Reset progress bar | |
$progress = \WP_CLI\Utils\make_progress_bar( 'Deleting orphaned fields', count( $posts ) ); | |
$deleted = 0; | |
foreach ( $posts as $post ) { | |
$post_id = $post->ID; | |
$post_meta = get_post_meta($post_id); | |
foreach ( $post_meta as $meta_key => $meta_values ) { | |
// Skip non-ACF fields | |
if ( !is_acf_field_key($meta_key) && !is_acf_field_ref($meta_key) ) { | |
continue; | |
} | |
$meta_value = $meta_values[0]; | |
// Determine if this is a field key or reference | |
$is_field_ref = is_acf_field_ref($meta_key); | |
$field_key = $is_field_ref ? $meta_value : $meta_key; | |
// Only process valid field keys | |
if ( strpos($field_key, 'field_') !== 0 ) { | |
continue; | |
} | |
// Delete if orphaned | |
if ( !in_array($field_key, $existing_field_keys) ) { | |
if ( delete_post_meta( $post_id, $meta_key ) ) { | |
$deleted++; | |
} | |
} | |
} | |
$progress->tick(); | |
} | |
$progress->finish(); | |
$success_message = sprintf( 'Deleted %d orphaned ACF field entries.', $deleted ); | |
$logger( $success_message ); | |
// Add deletion report to log if enabled | |
if ($log_enabled) { | |
$log_contents .= "\n\n==== DELETION REPORT ====\n"; | |
$log_contents .= $success_message . "\n"; | |
file_put_contents($log_file, $log_contents); | |
WP_CLI::success("Updated log saved to: {$log_file}"); | |
} | |
} | |
} | |
/** | |
* Check if a meta key is an ACF field key | |
*/ | |
function is_acf_field_key( $meta_key ) { | |
return strpos( $meta_key, 'field_' ) === 0; | |
} | |
/** | |
* Check if a meta key is an ACF field reference (starts with underscore) | |
*/ | |
function is_acf_field_ref( $meta_key ) { | |
return strpos( $meta_key, '_' ) === 0 && strpos( $meta_key, '_field_' ) !== 0; | |
} | |
/** | |
* Recursively get all field keys from ACF fields | |
*/ | |
function get_all_acf_field_keys( $fields, &$keys = array() ) { | |
foreach ( $fields as $field ) { | |
$keys[] = $field['key']; | |
// Handle sub-fields for repeaters, flexible content, etc. | |
if ( !empty( $field['sub_fields'] ) ) { | |
get_all_acf_field_keys( $field['sub_fields'], $keys ); | |
} | |
// Handle layouts for flexible content fields | |
if ( !empty( $field['layouts'] ) ) { | |
foreach ( $field['layouts'] as $layout ) { | |
if ( !empty( $layout['sub_fields'] ) ) { | |
get_all_acf_field_keys( $layout['sub_fields'], $keys ); | |
} | |
} | |
} | |
} | |
return $keys; | |
} | |
/** | |
* Truncate meta value for display | |
*/ | |
function truncate_meta_value( $value ) { | |
if ( is_array( $value ) ) { | |
$value = json_encode( $value ); | |
} elseif ( is_object( $value ) ) { | |
$value = json_encode( $value ); | |
} | |
if ( is_string( $value ) ) { | |
if ( strlen( $value ) > 50 ) { | |
return substr( $value, 0, 47 ) . '...'; | |
} | |
return $value; | |
} | |
return print_r( $value, true ); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment