D
P
0

WordPress & PHP

Filter `post_link` Benar tapi Sebagian Post Tetap Tanpa Prefix Kategori? Permalink Manager Lite Menimpa di Priority 99

30 Juli 2026·5 menit baca
Filter `post_link` Benar tapi Sebagian Post Tetap Tanpa Prefix Kategori? Permalink Manager Lite Menimpa di Priority 99

Sebuah situs klien yang saya pegang punya satu aturan permalink yang tegas: setiap post artikel harus tampil dengan kategori di depan slug, jadi bentuknya /{kategori}/{slug}/. Theme-nya sudah menyimpan filter post_link untuk itu, kode yang lurus dan menurut saya deterministik:

add_filter('post_link', 'theme_category_permalink', 10, 2);
 
function theme_category_permalink($url, $post) {
    $terms = get_the_terms($post->ID, 'category');
    if (empty($terms) || is_wp_error($terms)) {
        return $url;
    }
    return home_url('/' . $terms[0]->slug . '/' . $post->post_name . '/');
}

Masalahnya, output-nya beda-beda antar post. Sebagian artikel dapat /{kategori}/{slug}/ persis seperti yang saya mau. Sebagian lagi tetap nangkring di root sebagai /{slug}/, tanpa prefix kategori sama sekali. Taxonomy-nya identik, meta-nya identik, dan kode di atas jelas cuma punya satu jalur untuk semua post. Kode sama, input sama, output beda. Itu yang bikin saya penasaran setengah mati, karena bug yang tidak deterministik di kode yang deterministik selalu berarti ada tangan lain yang ikut main.

Tiga teori yang semuanya salah

Teori pertama: file basi. Saya pikir mungkin ada versi lama theme yang nyangkut di server, jadi saya rsync ulang, bandingkan checksum, pastikan file di produksi persis sama dengan repo. Tidak ada bedanya. Post yang salah tetap salah.

Teori kedua: opcache. Barangkali PHP menyimpan bytecode lama yang memakai logika permalink versi sebelumnya. Saya reset opcache, restart PHP-FPM. Nol perubahan.

Teori ketiga, yang paling masuk akal di kepala saya waktu itu: urutan hook. Mungkin filter saya jalan di prioritas yang salah dan ada sesuatu yang menimpanya. Saya coba geser prioritas dari 10 ke 5, lalu ke 20. Tetap acak. Post yang sama tetap tanpa kategori. Tiga jam habis untuk tiga tebakan, dan tidak satu pun menyentuh akar masalahnya.

Menemukan pelakunya

Saya berhenti menebak dan mulai menginterogasi filter itu langsung. Trik pertama: sentinel probe. Saya suapkan string penanda yang tidak mungkin muncul secara alami sebagai input post_link, lalu lihat apa yang keluar:

$out = apply_filters('post_link', 'PROBE-INPUT', get_post($id), false);
error_log('post_link sentinel -> ' . $out);

Kalau filter di rantai itu menghormati input, saya harusnya melihat jejak PROBE-INPUT di output. Yang saya dapat malah permalink lengkap tanpa satu huruf pun dari sentinel saya. Artinya jelas: ada filter di hilir yang mengabaikan $url yang masuk dan menggantinya total, wholesale, bukan memodifikasi.

Sekarang saya perlu tahu siapa. Saya bikin mu-plugin diagnostik sementara yang membongkar seluruh callback di hook post_link lengkap dengan file dan baris asalnya, pakai Reflection:

add_action('init', function () {
    global $wp_filter;
    foreach ($wp_filter['post_link']->callbacks as $prio => $cbs) {
        foreach ($cbs as $cb) {
            $fn  = $cb['function'];
            $ref = is_array($fn)
                ? new ReflectionMethod($fn[0], $fn[1])
                : new ReflectionFunction($fn);
            error_log(sprintf(
                'post_link prio %d -> %s:%d',
                $prio, $ref->getFileName(), $ref->getStartLine()
            ));
        }
    }
});

Log-nya langsung menelanjangi semuanya. Filter theme saya ada di prioritas 10, seperti yang saya harapkan. Tapi di prioritas 99 ada satu callback lagi yang tidak pernah saya daftarkan, dan file-nya menunjuk ke wp-content/plugins/permalink-manager/. Plugin yang bahkan tidak ada di inventori yang saya terima.

Akar masalahnya

Pelakunya Permalink Manager Lite, dipasang diam-diam oleh tim SEO sebelum saya masuk. Plugin ini hook post_link di prioritas 99, jauh di atas filter theme, dan alih-alih menyesuaikan URL yang sudah dibangun theme, ia mengambil permalink dari tabel penyimpanannya sendiri per-post, option bernama permalink-manager-uris, lalu menimpanya bulat-bulat.

Ini menjelaskan keacakan yang bikin saya pusing. Post yang tampil benar dengan /{kategori}/{slug}/ ternyata bukan hasil filter theme sama sekali. Itu post yang dulu pernah diketik URI-nya secara manual oleh tim SEO di metabox plugin, kebetulan dalam format yang sama. Post yang tidak pernah disentuh di metabox itu dapat URI default versi plugin, yaitu slug di root tanpa kategori. Jadi yang saya kira variasi acak sebenarnya peta persis dari post mana yang pernah diedit manual dan mana yang tidak. Kodenya memang deterministik. Cuma bukan kode saya yang menentukan output-nya.

Satu detail yang sempat menyesatkan verifikasi saya: edge cache di depan situs mengabaikan query string, jadi menambah ?nocache=1 tidak menembus cache HTML sama sekali. Satu-satunya oracle yang benar-benar live adalah REST API, karena ia menghitung permalink segar tiap request:

curl -s 'https://situs-klien.example/wp-json/wp/v2/posts/123' | jq -r '.link'

Nilai .link di situ adalah permalink hasil filter yang sesungguhnya, tanpa terhalang cache halaman.

Perbaikannya

Saya tidak mau dua sistem berebut kepemilikan URL. Langkah pertama: audit isi permalink-manager-uris. Ada 54 entri, saya klasifikasikan mana yang memang disengaja dan mana yang cuma default auto-generate. Setelah peta itu jelas, saya nonaktifkan plugin sepenuhnya supaya theme kembali jadi pemilik tunggal URL.

Tapi menonaktifkan plugin memunculkan masalah kedua: banyak URL root lama sudah terindeks. Setelah plugin mati, canonical berpindah ke /{kategori}/{slug}/, dan request ke /{slug}/ root perlu 301 ke canonical. Di sini ada jebakan: redirect_canonical bawaan core TIDAK menangani ini untuk permalink yang dibangun lewat filter. Core cuma tahu permastruct default, bukan URL buatan filter theme. Jadi saya pasang handler template_redirect sendiri:

add_action('template_redirect', function () {
    if (!is_singular('post')) {
        return;
    }
    $canonical = get_permalink(get_queried_object_id());
    $current   = home_url($_SERVER['REQUEST_URI']);
    if (untrailingslashit(strtok($current, '?')) !== untrailingslashit($canonical)) {
        wp_safe_redirect($canonical, 301);
        exit;
    }
});

Sekarang setiap post punya satu canonical yang dihitung theme, dan URL root lama dialihkan permanen ke bentuk berkategori. Satu sistem, satu pemilik, satu sumber kebenaran.

Pelajaran

  • Bug tidak deterministik di kode deterministik berarti ada tangan lain. Curigai plugin atau filter yang tidak kamu daftarkan sendiri.
  • Sentinel probe lewat apply_filters dengan input penanda cepat membuktikan apakah ada filter yang menimpa wholesale, bukan memodifikasi.
  • Dump $wp_filter['post_link'] dengan Reflection untuk dapat file:line tiap callback. Itu memberi tahu siapa yang ikut hook, bukan tebakan.
  • Waspadai plugin yang dipasang orang lain dan tidak masuk inventori. Permalink Manager Lite hook di prioritas 99 dan menimpa dari tabel sendiri.
  • Kalau edge cache mengabaikan query string, REST API adalah oracle live yang jujur untuk permalink.
  • redirect_canonical core tidak meng-301 root ke canonical untuk permalink hasil filter. Pasang template_redirect sendiri.