D
P
0

WordPress & Elementor

Icon Tombol Kosong dan Gambar 404 Setelah Migrasi Elementor? Remap Attachment ID di `_elementor_data` Harus Rekursif

3 Juli 2026·5 menit baca
Icon Tombol Kosong dan Gambar 404 Setelah Migrasi Elementor? Remap Attachment ID di `_elementor_data` Harus Rekursif

Saat memindahkan sebuah situs klien berbasis Elementor dari satu instalasi WordPress ke instalasi lain, ada satu konsekuensi yang tidak bisa dihindari: media library di-import ulang, dan setiap attachment mendapat ID baru. Masalahnya, Elementor tidak mereferensikan media lewat URL saja — dia menyimpan ID attachment di dalam JSON _elementor_data milik tiap halaman. Kalau ID-nya tidak di-remap, halaman di situs baru akan menunjuk ke attachment yang salah, atau ke attachment yang tidak ada sama sekali.

Jadi saya tulis remapper. Skripnya membaca _elementor_data tiap halaman, menyusuri settings setiap elemen, dan menukar ID lama dengan ID baru lewat map yang saya bangun saat import media. Saya tangani bentuk-bentuk yang jelas: image.id, background_image.id, dan array galeri. Skrip jalan tanpa error, hampir semua gambar tampil sempurna di situs baru, dan sempat ada momen saya merasa selesai.

"Hampir semua" itulah masalahnya. Icon di semua tombol hilang — kosong begitu saja, tanpa error apa pun. Dan beberapa gambar di widget tertentu malah 404. Begitu saya inspect URL-nya, gambar-gambar itu masih menunjuk ke domain situs lama. Remapper saya sukses di permukaan tapi bocor di bawahnya.

Kenapa ini terjadi

Saya ambil satu halaman yang rusak, dump _elementor_data-nya, dan cari widget tombolnya. Akar masalahnya langsung menatap balik. Walker saya hanya turun satu level ke dalam setiap value setting. Padahal widget tombol menyimpan icon-nya di selected_icon.value.{id,url} — dua level ke bawah: selected_icon adalah objek yang property value-nya adalah objek lain lagi yang berisi id dan url. Walker saya melihat selected_icon, mengecek apakah ada key id langsung di situ, tidak menemukan, lalu lewat begitu saja.

Dan itu baru satu widget. Repeater menyimpan seluruh array settings di dalam tiap item-nya, jadi media di dalam repeater terkubur lebih dalam lagi. Beberapa widget lain menyimpan objek id plus url di struktur yang lebih dalam dari dua level. Field icon punya jebakan tambahan: kalau icon-nya font icon, selected_icon.value cuma string seperti "fas fa-star"; kalau icon-nya SVG upload, value berubah jadi objek berisi id dan url. Field yang sama, dua bentuk berbeda.

Kesimpulan yang tidak enak tapi jujur: remapper shape-by-shape yang dangkal tidak akan pernah bisa mengenumerasi semua bentuk. Elementor punya ratusan widget, widget pihak ketiga menambah lagi, dan masing-masing bebas menaruh referensi media di kedalaman berapa pun. Setiap bentuk yang saya tambahkan ke daftar hanya menunggu bentuk berikutnya yang belum saya kenal.

Perbaikannya

Ini versi pertama saya, yang kelihatan masuk akal tapi diam-diam penuh lubang:

// 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);

Perbaikannya bukan menambah satu cabang if lagi. Perbaikannya adalah membuang seluruh daftar bentuk dan menggantinya dengan walker rekursif. Aturannya cuma dua. Pertama, turun ke setiap array dan objek, sedalam apa pun. Kedua, deteksi referensi media secara struktural, bukan dari nama field: kalau sebuah node punya key id numeric yang ada di map, plus key url yang menunjuk ke path uploads, itu referensi media — tukar id-nya lewat map dan tulis ulang host dan path url-nya ke lokasi uploads baru. Sebagai pelengkap, field id numeric polos yang nama key-nya berbau media ikut di-remap.

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);
}

Perhatikan apa yang hilang: tidak ada lagi penanganan khusus untuk galeri, background, atau tombol. Item galeri adalah objek id plus url, jadi deteksi struktural menangkapnya. selected_icon.value untuk SVG juga objek id plus url, tertangkap juga. Repeater sedalam apa pun tertangkap karena rekursinya buta terhadap makna — dia cuma turun.

Lalu terapkan dan simpan ulang:

$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
}

delete_post_meta untuk _elementor_css itu penting. Elementor menge-cache CSS per halaman, dan CSS itu bisa mengandung URL media (background image, misalnya). Kalau _elementor_data berubah tapi CSS-nya tidak di-regenerate, kamu akan menatap halaman yang datanya sudah benar tapi tampilannya masih basi. Regenerate massal juga bisa lewat Elementor > Tools > Regenerate CSS & Data.

Untuk validasi, saya ambil satu halaman, simpan JSON-nya sebelum dan sesudah remap, lalu diff keduanya. Yang boleh berubah hanya angka ID dan host URL — kalau ada perubahan lain, berarti walker-nya terlalu agresif.

Pelajaran

Empat hal yang saya bawa pulang dari kasus ini:

  • Di schema yang bukan milikmu, jangan pernah mengenumerasi bentuk. Kamu tidak akan menang melawan ratusan widget. Rekursi menang karena dia tidak perlu tahu bentuknya.
  • Deteksi referensi media secara struktural — pasangan id plus url — bukan dari nama field. Nama field adalah kontrak yang tidak pernah ditandatangani siapa pun.
  • Diff JSON satu halaman sebelum dan sesudah remap dulu, baru jalankan ke semua halaman. Lima menit yang murah dibanding migrasi ulang.
  • Setiap kali _elementor_data berubah, regenerate CSS Elementor. Data yang benar dengan CSS yang basi tetap kelihatan seperti bug.

Icon tombol yang kosong itu akhirnya bukan misteri Elementor. Itu cuma saya, yang mengira dua level sudah cukup dalam.