Pengguna mengirim form pemesanan, melihat halaman “pesanan diterima”, lalu tanpa sengaja menekan F5. Browser menanyakan “kirim ulang data?” dan pengguna menekan Ya — tiba-tiba ada dua pesanan identik di database. Inilah masalah submit ganda, dan hampir semua aplikasi yang memproses form klasik pernah mengalaminya. Pada chapter ini kita mempelajari solusinya: pola Post/Redirect/Get (PRG), cara mengirim respons 303 See Other dengan header(), dan sinerginya dengan flash message berbasis session.
Masalah Submit Ganda #
Akar masalahnya ada pada perilaku tombol refresh: browser mengulang request terakhir yang tercatat di riwayat navigasi. Jika request terakhir adalah POST yang merender halaman konfirmasi langsung, refresh mengirim ulang POST lengkap dengan datanya — server memprosesnya lagi tanpa tahu bahwa ini keulangan. Akibatnya bervariasi dari yang lucu (komentar dobel) sampai yang mahal (pembayaran ganda, email terkirim dua kali). Pola render-langsung-setelah-POST juga membuat tombol back dan bookmark berperilaku aneh, karena halaman konfirmasi tidak punya URL yang bisa dikunjungi ulang.
Alur Post/Redirect/Get #
PRG memutus siklus tersebut dengan memindahkan hasil POST ke request GET yang terpisah:
Bacanya begini: server menerima POST, memprosesnya, lalu menjawab bukan dengan halaman hasil melainkan dengan perintah “silakan minta ulang ke alamat ini”. Browser menaatinya secara otomatis dengan GET ke URL baru. Kini request terakhir dalam riwayat browser adalah GET — menekan refresh hanya mengambil ulang halaman konfirmasi yang sudah aman, tanpa data form apa pun. Bonusnya, halaman konfirmasi punya URL yang bisa di-bookmark dan dibagikan.
Keputusan Redirect sebagai Fungsi Teruji #
Inti PRG sebenarnya hanyalah sebuah keputusan bercabang: validasi sukses berarti redirect, validasi gagal berarti render ulang form. Dengan memisahkan keputusan itu ke dalam fungsi murni, logikanya bisa diuji tanpa web server:
<?php
function prosesPemesanan(array $post): array
{
$errors = [];
$menu = trim($post['menu'] ?? '');
$jumlah = (int) ($post['jumlah'] ?? 0);
if ($menu === '') {
$errors['menu'] = 'Menu wajib dipilih.';
}
if ($jumlah < 1) {
$errors['jumlah'] = 'Jumlah minimal 1.';
}
if ($errors === []) {
// simpan pesanan, lalu minta browser meminta ulang halaman hasil
return ['action' => 'redirect', 'location' => '/pesanan/sukses'];
}
// ada error: render ulang form seperti biasa (status 200)
return ['action' => 'render', 'errors' => $errors];
}
print_r(prosesPemesanan(['menu' => 'Nasi Goreng Spesial', 'jumlah' => '2']));
print_r(prosesPemesanan(['menu' => '', 'jumlah' => '0']));Output yang Dihasilkan:
Array
(
[action] => redirect
[location] => /pesanan/sukses
)
Array
(
[action] => render
[errors] => Array
(
[menu] => Menu wajib dipilih.
[jumlah] => Jumlah minimal 1.
)
)Fungsi di atas mengembalikan dua bentuk hasil yang berbeda: pasangan redirect + location ketika pesanan valid, dan render + errors ketika validasi gagal. Validasinya sendiri mengikuti pola error bag dari chapter Menampilkan Validation Errors
— perhatikan bahwa render ulang form dengan error justru tidak boleh lewat redirect, karena error bag harus sampai ke request yang sama. Lapisan web tinggal menerjemahkan hasil fungsi ini menjadi respons HTTP yang sesungguhnya.
Mengirim Respons 303 See Other #
Penerjemahan tersebut dilakukan dengan header() dan wajib diakhiri exit:
<?php
$keputusan = prosesPemesanan($_POST);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $keputusan['action'] === 'redirect') {
// 303 See Other: "silakan ambil hasilnya dengan GET"
header('Location: ' . $keputusan['location'], true, 303);
exit;
}
// ... render ulang form bersama $keputusan['errors']
Blok ini membutuhkan SAPI web agar header() benar-benar mengirim respons, jadi kita tidak mengeksekusinya di sesi CLI. Tiga hal penting dari ketiga baris intinya:
header('Location: ...', true, 303): parameter kedua mengganti header sejenis bila ada, parameter ketiga menetapkan status code. Tanpa parameter ketiga, PHP mengirim302 Found.exitsetelah redirect bersifat wajib:header()hanya menandai header yang akan dikirim — eksekusi script tetap lanjut ke baris berikutnya. Tanpaexit, kode render ulang form ikut berjalan dan output-nya merusak respons redirect.303dipilih, bukan302:302 Foundsecara historis ambigu soal method yang harus dipakai browser setelahnya (praktik browser mengubah POST menjadi GET, tapi perilaku ini lahir dari kesepahaman, bukan spesifikasi).303 See Othermenegaskan secara eksplisit: respons ini harus diminta ulang dengan GET, apa pun method aslinya.
Untuk memastikan perilakunya nyata, kita jalankan handler di atas pada web server bawaan PHP (php -S) lalu mengirim POST dengan curl -i:
$ curl -i -X POST http://127.0.0.1:8362/pesanan
HTTP/1.1 303 See Other
Host: 127.0.0.1:8362
Date: Tue, 13 Oct 2026 12:48:26 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Location: /pesanan/sukses
Content-type: text/html; charset=UTF-8
$ curl -i http://127.0.0.1:8362/pesanan/sukses
HTTP/1.1 200 OK
Host: 127.0.0.1:8362
Date: Tue, 13 Oct 2026 12:48:26 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: text/html; charset=UTF-8
Terima kasih, pesanan Anda kami terima.Transkrip di atas diambil langsung dari sesi pengujian yang sesungguhnya. Respons pertama berisi status 303 See Other dengan header Location dan tidak berisi body halaman — perhatikan bahwa setelah header kosong, tidak ada HTML apa pun. Respons kedua, hasil GET browser ke alamat Location, adalah halaman konfirmasi biasa berstatus 200 OK. Detail status code dan pola redirect lainnya dibahas tuntas di chapter Redirect Responses
.
Sinergi dengan Flash Message #
Ada satu kekosongan yang ditinggalkan PRG: setelah redirect, halaman konfirmasi dijalankan sebagai request baru yang tidak mengenal variabel-variabel dari POST sebelumnya. Bagaimana menampilkan “Pesanan Anda berhasil kami terima”? Jawabannya: simpan pesan di session sebelum redirect, lalu baca dan hapus sekali di sisi GET:
<?php
// CLI mendukung session; ID tetap dipakai agar latihan ini deterministik
session_id('prg-demo');
session_start();
// ---- Sisi POST: simpan pesan sebelum redirect ----
if (!isset($_SESSION['flash'])) {
$_SESSION['flash'] = 'Pesanan Anda berhasil kami terima.';
}
// ---- Sisi GET pertama: baca pesan lalu segera hapus ----
$pesanFlash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
echo $pesanFlash !== null ? $pesanFlash . "\n" : "(tidak ada pesan flash)\n";
// ---- Sisi GET kedua: pesan sudah hilang ----
$pesanFlash = $_SESSION['flash'] ?? null;
echo $pesanFlash !== null ? $pesanFlash . "\n" : "(tidak ada pesan flash)\n";Output yang Dihasilkan:
Pesanan Anda berhasil kami terima.
(tidak ada pesan flash)Output di atas memperlihatkan siklus hidup flash message: baca pertama berhasil menemukan pesan, baca kedua sudah tidak menemukan apa-apa karena unset() menghapusnya segera setelah dibaca. Sifat sekali-pakai inilah yang membuat flash berbeda dari data session biasa — refresh di halaman konfirmasi tidak akan menampilkan pesan yang sama dua kali. Kita menetapkan session_id('prg-demo') sebelum session_start() semata-mata agar latihan CLI ini menghasilkan output yang sama di setiap eksekusi; di aplikasi nyata session ID dikelola PHP secara otomatis. Pola selengkapnya — termasuk old input yang tersimpan dengan cara serupa — dibahas di chapter Flash Messages
.
Kapan Tidak Perlu PRG #
PRG adalah pola untuk form klasik yang berpindah halaman. Endpoint yang dipanggil JavaScript melalui fetch() tidak mengubah navigasi browser sama sekali — refresh tidak akan mengulang POST tersebut, dan konsumen API justru menuntut respons JSON, bukan redirect:
// Endpoint API merespons JSON; browser tidak berpindah halaman
const respons = await fetch("/api/pesanan", {
method: "POST",
body: new FormData(formElemen),
});
const data = await respons.json();Menerapkan PRG pada endpoint seperti ini malah merusak: klien fetch menerima status 303 dan harus mengikuti redirect untuk mendapatkan JSON, padahal ia ingin tahu hasil operasinya langsung. Aturan praktisnya: halaman HTML hasil operasi → PRG; data untuk konsumen program → respons langsung dengan status yang semestinya, seperti yang kita pelajari di chapter HTTP Methods
.
Ringkasan #
Pola Post/Redirect/Get menutup celah submit ganda dengan memastikan request terakhir di riwayat browser selalu berupa GET yang aman diulang. Implementasinya ringkas: keputusan redirect dipisahkan sebagai fungsi teruji, respons dikirim dengan header('Location: ...', true, 303) ditutup exit, dan pesan hasilnya menumpang di session sebagai flash message. Dengan PRG, pola validasi-render-redirect aplikasi kita lengkap — tinggal satu tema penutup yang menyatukan semuanya: menjaga form dari penyerang yang tidak berkunjung lewat browser yang kita bayangkan, pada chapter Form Security
.