Sebuah instalasi WordPress yang saya pegang baru saja terkontaminasi: proses seeding yang salah menyuntikkan ribuan post sampah, postmeta yatim, dan relasi term yang keliru ke seluruh database. Semuanya harus dihapus massal. Rencana saya standar dan membosankan, persis seperti seharusnya: tulis skrip cleanup kecil di root instalasi, boot WordPress, lalu pakai API-nya supaya semua hook dan integritas data tetap terjaga.
<?php
// cleanup.php - the obvious route
require __DIR__ . '/wp-load.php';
// ...WP_Query, wp_delete_post(), done. In theory.Skrip itu tidak pernah sampai ke baris kedua.
PHP Fatal error: Uncaught Error: Call to undefined function
render_listing_badge() in wp-content/themes/active-theme/functions.php on line 27Saya buka functions.php tema aktif, dan di situlah masalahnya: file itu memanggil beberapa fungsi helper yang bukan miliknya. Helper-helper itu selama ini disediakan oleh sebuah plugin code-snippets, dan plugin itu sudah dinonaktifkan saat insiden terjadi. Akibatnya bukan cuma skrip saya yang mati. SEMUA jalur bootstrap WordPress — wp-load.php, wp-admin, bahkan front-end — fatal di titik yang sama, sebelum satu baris pun kode saya sempat jalan. WP-CLI tidak tersedia di hosting itu, jadi --skip-themes juga bukan opsi.
Catch-22-nya
Situasinya lucu kalau tidak sedang menimpa kamu. Saya butuh API WordPress untuk membersihkan situs, tapi API itu hanya hidup kalau situsnya bisa boot, dan situsnya tidak bisa boot justru karena kotor. Alat cleanup saya bergantung pada benda yang sedang dia bersihkan.
Begitu dilihat dari sudut itu, jalan keluarnya jelas: jangan boot WordPress sama sekali. Yang benar-benar saya butuhkan cuma dua hal, kredensial database dan nama-nama tabelnya, dan dua-duanya bisa didapat tanpa mengeksekusi satu baris pun kode WordPress.
Langkah nol: dump dulu
Sebelum menyentuh apa pun, dump database. Ini bukan opsional; semua langkah setelah ini destruktif.
mysqldump -u backup_user -p wordpress_db > backup-before-cleanup.sqlBaca wp-config.php sebagai teks, jangan pernah dieksekusi
Refleks pertama pasti require 'wp-config.php'; untuk dapat konstanta DB. Jangan. Baris terakhir wp-config.php adalah require_once ke wp-settings.php, yang men-trigger bootstrap penuh yang sama dan fatal yang sama. File itu harus diperlakukan sebagai teks biasa, di-parse pakai regex:
$config = file_get_contents(__DIR__ . '/wp-config.php');
function config_value(string $config, string $key): string
{
$pattern = "/define\(\s*['\"]" . $key . "['\"]\s*,\s*['\"](.*?)['\"]\s*\)/";
if (!preg_match($pattern, $config, $m)) {
throw new RuntimeException("$key not found in wp-config.php");
}
return $m[1];
}
$dbName = config_value($config, 'DB_NAME');
$dbUser = config_value($config, 'DB_USER');
$dbPass = config_value($config, 'DB_PASSWORD');
$dbHost = config_value($config, 'DB_HOST');
preg_match('/\$table_prefix\s*=\s*[\'"](.+?)[\'"]/', $config, $m);
$prefix = $m[1];Jangan lupa $table_prefix. Berasumsi prefiksnya wp_ adalah cara klasik untuk menjalankan DELETE ke tabel yang tidak ada, atau lebih buruk, ke tabel yang salah di instalasi multi-site satu database.
mysqli mentah: dry-run dulu, transaksi selalu
Dengan kredensial di tangan, sisanya SQL biasa lewat mysqli. Aturan yang saya pegang keras: setiap query destruktif harus punya versi SELECT COUNT yang dijalankan duluan, dengan klausa WHERE yang identik. Kalau angkanya aneh, berhenti.
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$db = new mysqli($dbHost, $dbUser, $dbPass, $dbName);
$count = $db->query(
"SELECT COUNT(*) AS total FROM {$prefix}posts
WHERE post_type = 'listing'"
)->fetch_assoc()['total'];
echo "Would delete {$count} posts. Ctrl+C now if that looks wrong.\n";Angkanya cocok dengan perkiraan saya, jadi lanjut ke penghapusan sungguhan, dibungkus transaksi (tabelnya InnoDB, jadi rollback benar-benar berarti):
$db->begin_transaction();
$db->query(
"DELETE pm FROM {$prefix}postmeta pm
JOIN {$prefix}posts p ON p.ID = pm.post_id
WHERE p.post_type = 'listing'"
);
$db->query(
"DELETE tr FROM {$prefix}term_relationships tr
JOIN {$prefix}posts p ON p.ID = tr.object_id
WHERE p.post_type = 'listing'"
);
$db->query(
"DELETE FROM {$prefix}posts WHERE post_type = 'listing'"
);
$db->commit();Urutannya disengaja: meta dan relasi term dihapus selagi post induknya masih ada untuk di-JOIN, baru post-nya sendiri terakhir.
Ganti tema di level DB sebelum menyentuh data tema
Sebagian data kotor berkaitan dengan tema, dan di sinilah satu keputusan penting: fatal-nya sendiri datang dari functions.php tema aktif. Selama tema itu masih aktif, situs tidak akan pernah bisa boot, mau database-nya sebersih apa pun. Jadi sebelum menyentuh data apa pun yang berhubungan dengan tema, saya pindahkan tema aktif ke tema default langsung di tabel options:
$db->query(
"UPDATE {$prefix}options
SET option_value = 'twentytwentyfive'
WHERE option_name IN ('template', 'stylesheet')"
);Dua option itu, template dan stylesheet, adalah satu-satunya penentu tema aktif. Begitu UPDATE itu jalan, wp-admin langsung bisa dibuka lagi, karena functions.php yang bermasalah tidak pernah di-load.
Verifikasi, lalu nyalakan satu per satu
Setelah cleanup selesai, saya buka wp-admin: login normal, tidak ada fatal, jumlah konten sesuai harapan. Dari situ baru saya reaktivasi komponen satu per satu, plugin dulu, cek setelah masing-masing, dan tema lama paling terakhir, itu pun setelah helper yang hilang dikembalikan ke tempat yang benar. Bukan di plugin code-snippets lagi.
Checklist
- Alat cleanup tidak boleh bergantung pada benda yang sedang dia bersihkan. Kalau bootstrap-nya rusak, jangan lewat bootstrap.
- Parse
wp-config.phppakai regex sebagai teks. Meng-include-nya sama saja dengan boot WordPress. - Ambil
$table_prefixdari config, jangan diasumsikanwp_. mysqldumpdulu sebelum query destruktif pertama.- Setiap DELETE atau UPDATE punya kembaran
SELECT COUNTyang dijalankan duluan. - Ganti tema aktif di level DB sebelum menyentuh data tema, supaya situs bisa boot lagi setelahnya.
- Reaktivasi satu per satu sambil verifikasi, jangan sekaligus.
Sejak insiden itu, setiap kali menulis skrip perbaikan, pertanyaan pertama saya bukan "API apa yang saya butuhkan", tapi "apakah skrip ini masih jalan kalau pasiennya sekarat".
