Query string di chapter sebelumnya cocok untuk data opsional seperti nomor halaman, tetapi data yang dikirim pengguna — pendaftaran, pembuatan tugas, unggahan dokumen — dikirim lewat form HTML dengan HTTP method POST. Kita sudah mencicipi pembacaannya lewat $request->input() pada chapter Request Object ; sekarang kita rakit alur lengkapnya: form Blade yang merender HTML, route yang menerimanya, upload file, sampai pola old input yang mengembalikan isian pengguna ketika penyimpanan gagal. Validasi yang teliti sengaja kita tangguhkan — Laravel punya bagian khusus untuk itu di section selanjutnya.

Form HTML dan Route Tujuan #

Form kita taruh di view resources/views/tugas/create.blade.php. Atribut method="POST" menentukan HTTP method yang dipakai browser, sedangkan action menunjuk route bernama tugas.simpan yang akan kita daftarkan bersamanya:

blade
<form method="POST" action="{{ route('tugas.simpan') }}">
    @csrf

    <label for="nama">Nama tugas</label>
    <input type="text" id="nama" name="nama" value="{{ old('nama') }}">

    <label for="deskripsi">Deskripsi</label>
    <textarea id="deskripsi" name="deskripsi">{{ old('deskripsi') }}</textarea>

    <button type="submit">Simpan Tugas</button>
</form>

Directive @csrf adalah bagian penting yang tidak boleh dilupakan: directive tersebut merender hidden input _token berisi token acak dari session. Route web dilindungi middleware anti-request-forgery yang membandingkan _token dengan session — token yang hilang atau tidak cocok membuat request dijawab error 419 Page Expired. Karena nilainya acak setiap session, kita sembunyikan isinya saat menampilkan hasil render nyata berikut:

Output yang Dihasilkan:

text
<form method="POST" action="http://localhost:8000/tugas">
    <input type="hidden" name="_token" value="(token acak)" autocomplete="off">
    <label for="nama">Nama tugas</label>
    <input type="text" id="nama" name="nama" value="">

    <label for="deskripsi">Deskripsi</label>
    <textarea id="deskripsi" name="deskripsi"></textarea>

    <button type="submit">Simpan Tugas</button>
</form>

Dua route di belakangnya — satu untuk menampilkan form, satu untuk menerima kiriman:

php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/tugas/baru', function () {
    return view('tugas.create');
})->name('tugas.baru');

Route::post('/tugas', function (Request $request) {
    if (! $request->filled('nama')) {
        return redirect()->route('tugas.baru')->withInput();
    }

    return 'Tugas "'.$request->input('nama').'" disimpan.';
})->name('tugas.simpan');

Handler POST /tugas sementara ini hanya memeriksa filled() — pemeriksaan minimal agar kita bisa fokus pada alur form, bukan aturan validasi.

Membaca Data Form di Sisi Server #

Saat tombol Simpan Tugas ditekan, browser mengirim body request berisi pasangan nama=...&deskripsi=.... Karena request POST melewati pemeriksaan CSRF, kita uji lewat feature test yang menonaktifkannya otomatis:

php
<?php

// tests/Feature/FormDataTest.php (potongan)
$response = $this->post('/tugas', [
    'nama' => 'Belajar Form Laravel',
    'deskripsi' => 'Latihan mengirim form',
]);

dump($response->getContent());

Output yang Dihasilkan:

text
"Tugas "Belajar Form Laravel" disimpan."

Array kedua pada $this->post() menjadi body request, dan setiap field-nya terbaca di handler lewat $request->input('nama') — perilaku yang sudah kita kenal, karena input() membaca sumber body POST.

Upload File dengan multipart #

Form biasa tidak cukup untuk file; atribut enctype="multipart/form-data" wajib ditambahkan pada tag <form>, dan handler membacanya lewat $request->file():

php
use Illuminate\Support\Facades\Storage;

Route::post('/tugas/upload', function (Request $request) {
    $file = $request->file('lampiran');

    $path = $file->store('lampiran');

    return [
        'nama_asli' => $file->getClientOriginalName(),
        'ekstensi' => $file->extension(),
        'ukuran' => $file->getSize(),
        'folder_tersimpan' => dirname($path),
        'tersimpan' => Storage::exists($path),
    ];
})->name('tugas.upload');

Method store('lampiran') langsung menyimpan file ke disk default dengan nama unik berbasis hash — itulah kenapa kita menampilkan dirname($path) dan status Storage::exists() alih-alih path penuhnya, karena nama hash-nya berubah di setiap eksekusi. File dokumen.pdf sebesar 100 KB kita kirim dari feature test memakai file palsu bawaan Laravel:

php
Storage::fake();

$response = $this->post('/tugas/upload', [
    'lampiran' => UploadedFile::fake()->create('dokumen.pdf', 100),
]);

dump($response->json());

Output yang Dihasilkan:

text
array:5 [
  "nama_asli" => "dokumen.pdf"
  "ekstensi" => "pdf"
  "ukuran" => 102400
  "folder_tersimpan" => "lampiran"
  "tersimpan" => true
]

Old Input saat Penyimpanan Gagal #

Sekarang kita saksikan cabang gagal pada handler. Ketika nama kosong, handler tidak menampilkan error — handler mengarahkan pengguna kembali ke form sambil membawa withInput(), yang menyimpan seluruh input saat itu ke session sebagai flash data:

php
public function test_old_input_setelah_penyimpanan_gagal(): void
{
    $response = $this->post('/tugas', [
        'nama' => '',
        'deskripsi' => 'Catatan lama',
    ]);
    dump($response->status());
    dump($response->headers->get('Location'));

    $response = $this->get('/tugas/baru');
    // nilai _token acak disamarkan
    echo preg_replace(
        '/name="_token" value="[^"]+"/',
        'name="_token" value="(token acak)"',
        $response->getContent()
    );
}

Output yang Dihasilkan:

text
302
"http://localhost:8000/tugas/baru"
<form method="POST" action="http://localhost:8000/tugas">
    <input type="hidden" name="_token" value="(token acak)" autocomplete="off">
    <label for="nama">Nama tugas</label>
    <input type="text" id="nama" name="nama" value="">

    <label for="deskripsi">Deskripsi</label>
    <textarea id="deskripsi" name="deskripsi">Catatan lama</textarea>

    <button type="submit">Simpan Tugas</button>
</form>

Response pertama berstatus 302 dengan header Location menunjuk form. Pada render kedua, {{ old('deskripsi') }} di Blade terisi 'Catatan lama' — flash data dari request sebelumnya hidup tepat satu request berikutnya, sesuai mekanisme old input pada dokumentasi requests . Perhatikan pula bahwa value="" pada nama tetap kosong: string kosong sudah diubah middleware ConvertEmptyStringsToNull menjadi null sebelum di-flash, persis perilaku yang kita jumpai di chapter Request Object . Detail penggunaan old() bersama validasi dan @error akan dibahas pada chapter validasi.

Alur form kini lengkap dari sisi request: render, kirim, simpan atau kembali dengan isian lama. Tetapi browser dan client API tidak hanya mengirim data — mereka juga mengirim header seperti Accept dan Authorization yang menentukan format response dan identitas pemanggil. Chapter berikutnya mengajak kita membaca header-header tersebut lewat object Request.