D
P
0

WordPress & Elementor

Empty Button Icons and 404 Images After an Elementor Migration? The Attachment ID Remap of `_elementor_data` Must Be Recursive

July 3, 2026·5 min read
Empty Button Icons and 404 Images After an Elementor Migration? The Attachment ID Remap of `_elementor_data` Must Be Recursive

Moving a client's Elementor site from one WordPress install to another comes with one unavoidable consequence: the media library gets re-imported, and every attachment receives a new ID. The catch is that Elementor does not reference media by URL alone — it stores attachment IDs inside each page's _elementor_data JSON. If those IDs are not remapped, pages on the new install point at the wrong attachments, or at attachments that do not exist at all.

So I wrote a remapper. It read each page's _elementor_data, walked every element's settings, and swapped old IDs for new ones using the map I had built during the media import. I handled the obvious shapes: image.id, background_image.id, and gallery arrays. The script ran clean, almost every image rendered perfectly on the new site, and for a moment I felt done.

"Almost every" was the problem. The icons on every button were gone — just empty, no error anywhere. And a handful of images in certain widgets returned 404s. When I inspected their URLs, they were still pointing at the old site's domain. My remapper had succeeded on the surface and leaked underneath.

Why this happens

I took one broken page, dumped its _elementor_data, and hunted for the button widget. The root cause was staring right back at me. My walker only descended one level into each setting value. But the button widget stores its icon at selected_icon.value.{id,url} — two levels down: selected_icon is an object whose value property is yet another object holding the id and the url. My walker looked at selected_icon, checked for a direct id key, found none, and moved on.

And that was just one widget. Repeaters nest an entire settings array inside each item, so media inside a repeater is buried even deeper. Other widgets keep id plus url objects in structures more than two levels down. The icon field has an extra trap of its own: for a font icon, selected_icon.value is just a string like "fas fa-star"; for an uploaded SVG, value becomes an object holding id and url. Same field, two different shapes.

The uncomfortable but honest conclusion: a shallow, shape-by-shape remapper can never enumerate them all. Elementor ships hundreds of widgets, third-party ones add more, and every single one is free to nest media references at arbitrary depth. Every shape I added to my list was just waiting for the next shape I had not met yet.

The fix

Here is my first version, the one that looked reasonable and was quietly full of holes:

// Version 1: shape-by-shape, one level deep. Looks reasonable, silently incomplete.
foreach ($settings as $key => &$value) {
    if (is_array($value) && isset($value['id'], $map[$value['id']])) {
        $value['id'] = $map[$value['id']]; // catches image.id, background_image.id
    }
    if ($key === 'gallery' && is_array($value)) {
        foreach ($value as &$item) {
            if (isset($item['id'], $map[$item['id']])) {
                $item['id'] = $map[$item['id']];
            }
        }
        unset($item);
    }
}
unset($value);

The fix was not adding one more if branch. The fix was throwing away the entire shape list and replacing it with a recursive walker. There are only two rules. First, recurse into every array and object, no matter how deep. Second, detect media references structurally instead of by field name: if a node has a numeric id key that exists in the map, plus a url key pointing at an uploads path, it is a media reference — swap the id through the map and rewrite the url host and path to the new uploads location. As a complement, bare numeric id fields whose key name smells like media get remapped too.

function remap_media(&$node, array $map, string $old_uploads, string $new_uploads): void {
    if (!is_array($node)) {
        return;
    }
 
    // Structural detection: any node holding a numeric id known to the map
    // plus a url pointing at an uploads path is a media reference,
    // regardless of what the parent key is called.
    if (isset($node['id'], $node['url'])
        && is_numeric($node['id'])
        && isset($map[(int) $node['id']])
        && is_string($node['url'])
        && strpos($node['url'], '/wp-content/uploads/') !== false
    ) {
        $node['id']  = $map[(int) $node['id']];
        $node['url'] = str_replace($old_uploads, $new_uploads, $node['url']);
    }
 
    foreach ($node as $key => &$value) {
        if (is_array($value)) {
            // Recurse: repeaters, selected_icon.value, anything at any depth.
            remap_media($value, $map, $old_uploads, $new_uploads);
        } elseif (is_numeric($value)
            && isset($map[(int) $value])
            && preg_match('/(image|icon|media|attachment)(_id)?$/', (string) $key)
        ) {
            $value = $map[(int) $value]; // bare id fields whose name suggests media
        }
    }
    unset($value);
}

Notice what disappeared: there is no special handling for galleries, backgrounds, or buttons anymore. A gallery item is an id plus url object, so the structural check catches it. selected_icon.value for an SVG is an id plus url object, caught as well. Repeaters at any depth are caught because the recursion is blind to meaning — it just descends.

Then apply and re-save:

$raw  = get_post_meta($post_id, '_elementor_data', true);
$data = json_decode($raw, true);
 
if (is_array($data)) {
    remap_media(
        $data,
        $id_map, // [old_id => new_id]
        'https://old-site.com/wp-content/uploads',
        'https://new-site.com/wp-content/uploads'
    );
    update_post_meta($post_id, '_elementor_data', wp_slash(wp_json_encode($data)));
    delete_post_meta($post_id, '_elementor_css'); // force CSS rebuild for this post
}

The delete_post_meta for _elementor_css matters. Elementor caches per-page CSS, and that CSS can embed media URLs (background images, for example). If _elementor_data changes but the CSS never gets regenerated, you end up staring at a page whose data is correct but whose rendering is stale. Bulk regeneration also works via Elementor > Tools > Regenerate CSS & Data.

For validation, I took a single page, saved its JSON before and after the remap, and diffed the two. The only things allowed to change are ID numbers and URL hosts — anything else means the walker is too aggressive.

The takeaway

Four things I carried home from this one:

  • In a schema you do not control, never enumerate shapes. You will not out-list hundreds of widgets. Recursion wins because it does not need to know the shape.
  • Detect media references structurally — the id plus url pair — not by field name. Field names are a contract nobody ever signed.
  • Diff one page's JSON before and after the remap first, then run it against every page. Five cheap minutes versus a re-migration.
  • Any time _elementor_data changes, regenerate Elementor's CSS. Correct data with stale CSS still looks like a bug.

The empty button icons were never an Elementor mystery in the end. They were just me, assuming two levels was deep enough.