Post/Redirect/Get di PHP - Membuat Refresh Aman Tanpa Menganggap POST Idempotent
Sebuah form menyimpan record baru, browser menampilkan halaman berhasil, lalu seseorang menekan refresh. Apakah refresh itu seharusnya hanya menampilkan hasil lagi, atau justru mengirim form semula untuk kedua kalinya? Peringatan browser yang familiar tentang pengiriman ulang data memperlihatkan sebuah detail arsitektur: halaman pada address bar masih merupakan respons dari request yang mengubah state.
Post/Redirect/Get, yang biasanya disingkat PRG, memberi interaksi itu bentuk yang lebih rapi. Setelah menerima POST, server mengarahkan browser ke resource yang dapat diambil dengan GET. Refresh kemudian mengulangi pengambilan, bukan pengiriman form. Ini berguna, tetapi janjinya lebih sempit daripada ungkapan “mencegah pengiriman duplikat” yang kadang dilekatkan padanya. PRG mengubah navigasi browser berikutnya; pola ini tidak membuat write awal pasti hanya terjadi sekali.
Pola ini adalah percakapan tiga pesan
Bayangkan sebuah form yang membuat catatan. Tanpa redirect, browser mengirim POST /notes dan langsung menerima halaman berhasil. Dokumen saat ini karena itu terhubung dengan respons POST. Memuat ulang dokumen tersebut dapat mengharuskan browser mengirim body POST lagi.
Dengan PRG, pertukaran itu memiliki tiga langkah:
- Browser mengirim
POST /notesdengan data form. - Setelah server melakukan commit terhadap catatan baru, server merespons dengan
303 See OtherdanLocation: /notes/42. - Browser mengikuti lokasi tersebut dengan
GET /notes/42dan merender hasilnya.
POST /notes HTTP/1.1
Host: example.test
Content-Type: application/x-www-form-urlencoded
title=A%20small%20question
HTTP/1.1 303 See Other
Location: /notes/42
Content-Length: 0
GET /notes/42 HTTP/1.1
Host: example.test
Halaman terakhir sekarang merepresentasikan sebuah GET. Biasanya halaman itu dapat di-refresh, diberi tautan, atau di-bookmark tanpa mengulangi operasi create. RFC 9110 mendefinisikan 303 secara khusus sebagai redirect ke resource lain yang representasinya dapat diambil dengan GET atau HEAD. Referensi MDN tentang 303 menunjukkan use case pengiriman form yang sama.
Ini bukan sekadar perubahan URL kosmetik. Pola tersebut memisahkan command dari representasi hasilnya. POST mengatakan “coba buat catatan ini.” GET setelahnya mengatakan “tampilkan catatan 42.” Kedua request itu memiliki tujuan berbeda dan dapat memiliki perilaku caching, authorization, dan error yang berbeda.
Mengapa 303 lebih jelas daripada 302 yang tidak disengaja
Banyak aplikasi memakai 302 Found setelah form dan tampak berfungsi. Perilaku itu memiliki dasar historis: RFC 9110 mengizinkan user agent mengubah POST menjadi GET ketika mengikuti 302. Tidak perlu menyatakan bahwa setiap alur 302 yang sudah ada pasti rusak.
Namun, 303 See Other menyatakan transisi yang dimaksud secara langsung. Request berikutnya mengambil resource yang dipilih alih-alih mengulangi operasi semula. Pembaca route, test, atau HTTP trace tidak perlu menyimpulkan bahwa aplikasi bergantung pada perilaku historis 302 yang mengubah POST menjadi GET.
307 Temporary Redirect memiliki arti berbeda. Sifat utamanya adalah user agent tidak boleh mengubah request method ketika mengikuti redirect. POST yang diarahkan dengan 307 tetap menjadi POST. Ini berguna ketika sebuah endpoint berpindah sementara dan operasi yang sama harus mencapai URI lain, tetapi merupakan kebalikan dari tujuan PRG yang umum. Status permanen yang juga mempertahankan method adalah 308.
Karena itu, status code sebaiknya mengikuti semantik yang dimaksud, bukan aturan bahwa satu redirect selalu lebih baik:
- Gunakan
303ketika POST yang selesai seharusnya mengarah ke representasi GET. - Gunakan
307atau308ketika request yang diarahkan harus mempertahankan method dan body. - Perlakukan
302sebagai redirect sementara dengan perilaku historis yang dapat mengubah method, bukan sebagai cara paling jelas untuk menjelaskan PRG.
Contoh PHP yang sengaja dibuat kecil
Handler berikut menggambarkan batas keberhasilan. Contoh ini mengasumsikan bahwa authentication, authorization, validasi CSRF, batas panjang input, dan koneksi PDO sudah tersedia. Contoh ini juga mengasumsikan bahwa notes.id adalah integer auto-increment. Kontrol yang tidak ditampilkan tersebut merupakan bagian dari aplikasi di sekelilingnya, bukan detail produksi yang opsional.
<?php
declare(strict_types=1);
$title = trim((string) ($_POST['title'] ?? ''));
if ($title === '') {
http_response_code(422);
renderNoteForm(['title' => 'A title is required.'], $_POST);
exit;
}
$statement = $pdo->prepare(
'INSERT INTO notes (title) VALUES (:title)'
);
$statement->execute(['title' => $title]);
$noteId = (int) $pdo->lastInsertId();
header('Location: /notes/' . $noteId, true, 303);
exit;
Redirect baru terjadi setelah insert berhasil. Jika validasi gagal, belum ada catatan yang dapat ditampilkan, sehingga handler merender error yang berguna alih-alih berpura-pura bahwa command sudah selesai. Jika write ke database melempar exception, penanganan error normal harus mengambil alih; mengirim redirect berhasil akan menciptakan konfirmasi palsu.
Dokumentasi resmi PHP untuk header() mencatat tiga detail yang relevan. Header harus dijadwalkan sebelum output respons, Location tanpa status eksplisit biasanya menghasilkan 302 kecuali status yang sesuai sudah ditetapkan, dan argumen ketiga dapat menetapkan response code. exit langsung juga disengaja: redirect header tidak menghentikan script PHP dengan sendirinya.
Pesan berhasil dapat ditampilkan pada halaman GET langsung dari resource yang dibuat, atau dibawa sebagai state “flash” session yang berumur pendek. Flash state adalah urusan presentasi; record yang dibuat dan ID-nya tetap menjadi hasil yang durable. Pesan yang hilang saat refresh sebaiknya tidak menjadi satu-satunya bukti bahwa operasi berhasil.
Apa yang tidak dicegah PRG
Bayangkan seseorang melakukan double-click pada tombol submit cukup cepat hingga mengirim dua request POST. Keduanya dapat tiba di server sebelum salah satu respons diterima, sehingga belum ada 303 yang sempat memengaruhi navigasi browser. Dua tab dapat melakukan hal yang sama. Sebuah script dapat mengabaikan redirect. Koneksi dapat gagal setelah server melakukan commit tetapi sebelum client menerima respons, sehingga client tidak yakin dengan hasilnya.
Tidak satu pun kasus ini bertentangan dengan PRG. Semuanya terjadi pada batas yang berbeda. Pola ini mengatur apa yang seharusnya dilakukan user agent setelah satu respons POST; pola ini tidak menggabungkan dua request POST menjadi satu operasi.
Perbedaan ini mengikuti model HTTP. RFC 9110 mendefinisikan idempotency berdasarkan intended effect dari beberapa request identik. PUT dan DELETE didefinisikan sebagai method idempotent; POST tidak. Aplikasi dapat merancang operasi POST tertentu agar tahan terhadap pengulangan, tetapi redirect setelahnya tidak memberikan sifat tersebut.
Menonaktifkan tombol submit dengan JavaScript dapat mengurangi double-click yang tidak disengaja dan memberi tahu pengguna bahwa pekerjaan sedang berlangsung. Ini merupakan feedback antarmuka yang berguna, tetapi tidak dapat menjadi batas correctness. JavaScript dapat gagal, request dapat datang dari client lain, dan dua request mungkin sudah terbang sebelum tombol berubah.
Letakkan batas duplikat di tempat aturan bisnis berada
Server pertama-tama memerlukan jawaban yang presisi untuk “duplikat dalam arti apa?” Dua subscription newsletter untuk daftar dan alamat email yang sama mungkin merepresentasikan satu membership. Dua komentar dengan teks identik mungkin disengaja. Dua upaya pembayaran memerlukan kontrak yang lebih kuat dan spesifik terhadap operasi daripada sekadar membandingkan jumlahnya.
Beberapa kontrol sesuai untuk aturan yang berbeda:
- Database unique constraint cocok untuk invariant alami seperti satu membership per
(list_id, subscriber_id). MariaDB mendokumentasikan bahwaUNIQUEconstraint mengharuskan nilai atau kombinasi yang dibatasi hanya muncul sekali dan menolak pelanggaran. Di bawah concurrency, ini lebih kuat daripada memeriksa keberadaan row lalu melakukan insert sebagai dua langkah terpisah. - Operation token atau idempotency key cocok untuk tindakan create yang tidak memiliki nilai unik alami. Form menerima operation identifier yang tidak dapat ditebak, lalu server mencatat identifier itu bersama hasilnya. Pemakaian ulang mengembalikan atau merujuk pada hasil awal alih-alih menerapkan efek lagi. Token harus memiliki scope dan lifetime yang terdefinisi, dan mengklaimnya secara atomik bersama write sangat penting; lookup terpisah “apakah ini sudah pernah dilihat?” juga dapat mengalami race.
- Transaction menjaga operasi yang diterima dan duplicate marker tetap bersama ketika beberapa perubahan database membentuk satu keputusan. Transaction tidak membuat side effect eksternal terjadi exactly once. Email, payment API, dan queue tetap memerlukan kontrak retry dan reconciliation sendiri.
Kontrol-kontrol ini melengkapi PRG. Server melindungi operasi, sementara redirect memberi browser halaman hasil yang stabil. Yang satu merupakan batas integritas data; yang lain merupakan batas navigasi.
CSRF token menjawab pertanyaan lain
CSRF token kadang disalahartikan sebagai token pengiriman satu kali yang berlaku umum. Tujuan keamanannya berbeda: token ini membantu aplikasi dengan authentication berbasis cookie menolak request pengubah state yang dipalsukan dari konteks yang tidak dipercaya. Panduan CSRF OWASP merekomendasikan validasi backend terhadap pertahanan CSRF untuk request pengubah state dan memperingatkan agar GET tidak digunakan untuk mengubah state.
CSRF token yang valid tidak membuktikan bahwa pengguna berwenang yang sama belum mengirim operasi tersebut dua kali. Sebaliknya, duplicate-operation key tidak membuktikan bahwa request berasal dari origin atau interaksi pengguna yang diizinkan. Memisahkan kedua konsep ini membuat review lebih mudah, meskipun aplikasi menyimpan kedua state di session atau form yang sama.
Uji urutan dan race-nya
Satu klik di browser belum cukup untuk memverifikasi PRG. Periksa pertukaran network atau gunakan environment test yang aman untuk setidaknya menguji kasus berikut:
- POST yang valid mengembalikan
303denganLocationtepercaya yang diharapkan. - Mengikuti redirect menggunakan GET, dan me-refresh hasil tidak mengirim POST lagi.
- Input tidak valid tidak membuat record dan mempertahankan error form yang berguna.
- Write yang gagal tidak mengirim redirect berhasil.
- Dua request concurrent yang merepresentasikan operasi terlindungi yang sama menghasilkan satu business result yang diterima.
- Menggunakan kembali operation key menghasilkan respons yang terdefinisi alih-alih diam-diam menerapkan write lagi.
- Bukti CSRF yang hilang atau tidak valid ditolak secara terpisah dari duplicate handling.
Kasus concurrent adalah yang paling penting. Mengklik dua kali secara berurutan setelah setiap halaman selesai tidak menguji race yang seharusnya diselesaikan oleh storage constraint atau klaim token atomik.
Kesimpulan
Post/Redirect/Get menyelesaikan masalah yang nyata tetapi spesifik. Pola ini mengubah halaman setelah form POST berhasil menjadi resource GET, sehingga refresh dan bookmarking biasa tidak lagi menunjuk pada request yang mengubah state. Status 303 membuat transisi tersebut eksplisit, dan PHP dapat mengirimnya dengan respons yang kecil dan mudah dibaca.
Batas yang jujur sama pentingnya dengan pola itu sendiri. PRG tidak dapat menghentikan dua request POST yang sudah ada, menentukan apa yang termasuk operasi bisnis yang sama, atau mengautentikasi intent pengguna. Tugas tersebut berada pada aturan aplikasi, database constraint atau operation key, transaction bila sesuai, dan pertahanan CSRF. Alur form yang robust memakai lapisan-lapisan ini bersama-sama: lindungi write di server, kemudian arahkan browser ke representasi yang aman untuk dikunjungi kembali.
References
- Fielding, R., Nottingham, M., and Reschke, J., eds. RFC 9110: HTTP Semantics. IETF / RFC Editor, June 2022.
- WHATWG. HTML Standard: Form submission algorithm. Living Standard, accessed October 9, 2026.
- PHP Documentation Group. PHP Manual:
header. Accessed October 9, 2026. - MariaDB. MariaDB Server Documentation: CONSTRAINT. Accessed October 9, 2026.
- MDN contributors. 303 See Other. Last modified June 22, 2026.
- OWASP Cheat Sheet Series. Cross-Site Request Forgery Prevention Cheat Sheet. Accessed October 9, 2026.
