Gejalanya muncul beberapa jam setelah migrasi sebuah multi-listing site yang saya pindahkan. Google Search Console mulai meneriaki ratusan URL lama. Saya kira saya sudah aman: redirect handler-nya ada, sudah diuji di staging, dan secara logika "harusnya" mengubah URL lama jadi 301 ke URL baru. Tapi yang terjadi di produksi justru sebaliknya. Setiap URL lama yang masuk bukannya 301, malah balik dengan 404.
Ini contoh request yang gagal:
curl -I https://old-site.com/listing/cabin-by-the-lake
# HTTP/1.1 404 Not FoundDan ini inti redirect handler yang saya pasang di template_redirect. Idenya sederhana: ambil slug lama dari URL yang masuk, resolve ke term/post yang sesuai, lalu 301 ke permalink barunya.
add_action( 'template_redirect', function () {
if ( ! is_404() ) {
return;
}
$old_slug = get_query_var( 'name' ); // mis. "cabin-by-the-lake"
// Coba resolve slug lama ke entitas yang sesuai
$term = get_term_by( 'slug', $old_slug, 'listing_category' );
if ( ! $term ) {
return; // <- selalu mampir ke sini, jadi 404 dibiarkan
}
wp_safe_redirect( get_term_link( $term ), 301 );
exit;
} );Logikanya kelihatan benar. Tapi get_term_by() selalu mengembalikan null, jadi handler langsung return dan WordPress membiarkan request itu 404. Pertanyaannya: kenapa lookup slug lama bisa selalu gagal padahal datanya jelas ada di DB?
Kenapa ini terjadi
Karena migrasi itu mengubah dua hal sekaligus, di deploy yang sama: struktur URL berubah, dan slug-nya di-rename. Jadi entitas yang dulu punya slug cabin-by-the-lake sekarang sudah punya slug baru, katakanlah lakeside-cabin, di tabel yang sama yang saya query.
Begitu skrip migrasi selesai jalan, get_term_by('slug', 'cabin-by-the-lake', ...) mencari sesuatu yang sudah tidak ada lagi. Slug lama itu sudah ditimpa. Yang ada di DB cuma slug baru. Lookup balik null, handler return, dan request jatuh ke 404.
Kalau diringkas dalam satu kalimat: handler saya menanyakan ke database sebuah identitas yang baru saja saya hapus dengan tangan saya sendiri. Ini bukan masalah get_term_by() vs get_page_by_path() — keduanya akan sama-sama gagal. Masalahnya lebih fundamental: kamu tidak bisa me-resolve sebuah redirect dengan cara me-query benda yang baru saja kamu rename. Slug lama adalah satu-satunya kunci yang dimiliki request yang masuk, dan justru kunci itu yang sudah saya buang dari DB.
Ini yang bikin bug-nya licin di staging. Di staging awal saya kadang menguji setelah hanya struktur URL yang berubah tapi slug masih lama, atau saya mengetik URL baru langsung. Selama slug lama masih ada di DB, lookup berhasil dan redirect kelihatan jalan. Baru ketika rename slug ikut dieksekusi di deploy yang sama, fondasi tempat handler berpijak itu hilang.
Perbaikannya
Kuncinya: redirect handler tidak boleh bergantung pada DB live untuk identitas yang sudah berubah. Dia harus baca peta eksplisit lama → baru yang dibuat sebelum rename terjadi.
Skrip migrasi adalah satu-satunya tempat yang tahu kedua sisi cerita — dia tahu slug lama (sebelum) dan URL baru (sesudah). Jadi di situlah peta itu harus dibuat. Saya men-generate-nya saat migrasi, keyed by slug lama, lalu menyimpannya sebagai option.
// Dijalankan DI DALAM skrip migrasi, sebelum/sambil rename.
// Skrip ini tahu "sebelum" dan "sesudah", jadi dia bisa rekam keduanya.
$redirect_map = array();
foreach ( $entities_to_migrate as $entity ) {
// $entity->old_slug ditangkap sebelum di-rename.
$redirect_map[ $entity->old_slug ] = $entity->new_url; // URL final yang sudah jadi
}
update_option( 'listing_redirect_map', $redirect_map, false );Lalu handler-nya tidak lagi menyentuh DB live sama sekali untuk resolusi. Dia hanya membaca snapshot:
add_action( 'template_redirect', function () {
if ( ! is_404() ) {
return;
}
$old_slug = get_query_var( 'name' );
$map = get_option( 'listing_redirect_map', array() );
if ( empty( $map[ $old_slug ] ) ) {
return; // benar-benar tidak dikenal -> biarkan 404
}
wp_safe_redirect( $map[ $old_slug ], 301 );
exit;
} );Sekarang slug lama cabin-by-the-lake ketemu di peta, lookup-nya O(1), dan request balik 301 ke URL baru — tanpa peduli bahwa slug di DB sudah berubah jadi lakeside-cabin. Untuk site kecil sebuah static array di file juga cukup; intinya bukan di mana peta disimpan, tapi bahwa peta itu di-snapshot sebelum rename.
curl -I https://old-site.com/listing/cabin-by-the-lake
# HTTP/1.1 301 Moved Permanently
# Location: https://old-site.com/stays/lakeside-cabinPelajaran
Saat kamu me-rename benda yang seharusnya kamu query untuk me-resolve sebuah redirect, kamu tidak bisa menanyakan DB live untuk identitas lamanya — identitas itu sudah hilang. Snapshot pemetaannya sebelum kamu rename, dan jalankan semua 301 dari snapshot itu, bukan dari DB.
Lebih luas lagi: redirect bukan fitur runtime yang ditempel belakangan, dia adalah artefak dari migrasi itu sendiri. Satu-satunya momen di mana "sebelum" dan "sesudah" sama-sama ada di tangan kamu adalah saat skrip migrasi jalan. Tangkap pemetaannya di sana. Kalau kamu menunda dan baru mencari tahu URL lama itu mestinya kemana setelah datanya sudah berubah, kamu sedang menanyakan sesuatu ke database yang dengan sengaja sudah kamu buat lupa.
