Blok 2 · rozdział 6

Formularz i walidacja

Zanim zapiszesz zdobycie w bazie, zrób formularz „Planuję wyjście”. Sprawdza datę, wraca z błędami i pamięta wpis w sesji. W bloku 4 ten sam układ, czyli walidacja i przekierowanie, zapisze wiersz w tabeli zdobycia.

Po tym rozdziale powinieneś umieć

  • przyjąć POST w kontrolerze przez Request;
  • wstawić @csrf i powiedzieć, przed czym chroni;
  • zwrócić błędy przy polach (@error) i odtworzyć wpis (old());
  • po sukcesie zrobić redirect, żeby odświeżenie nie wysłało formularza drugi raz;
  • odróżnić komunikat na jedno odświeżenie od planu trzymanego w sesji do końca wizyty.

1. Formularz POST

Na stronie listy, pod siatką kart i wewnątrz @section('content'):

<form class="formularz" method="post" action="{{ route('plan.store') }}">
    @csrf
    <label>
        Szczyt
        <select name="slug">
            @foreach ($szczyty as $szczyt)
                <option value="{{ $szczyt['slug'] }}" @selected(old('slug') === $szczyt['slug'])>
                    {{ $szczyt['nazwa'] }}
                </option>
            @endforeach
        </select>
    </label>
    @error('slug')
        <p class="blad">{{ $message }}</p>
    @enderror
    <label>
        Data
        <input type="date" name="data" value="{{ old('data') }}">
    </label>
    @error('data')
        <p class="blad">{{ $message }}</p>
    @enderror
    <label>
        Notatka
        <input type="text" name="notatka" value="{{ old('notatka') }}" maxlength="200">
    </label>
    @error('notatka')
        <p class="blad">{{ $message }}</p>
    @enderror
    <button type="submit">Zapisz plan</button>
</form>

Formularz stoi na stronie listy, wewnątrz @section('content'), pod siatką kart. index już przekazuje $szczyty, więc @foreach ma po czym przejść.

Co jest w znaczniku, pole po polu:

  • method="post" wysyła dane w treści żądania, nie w adresie. Data i notatka nie zostają w pasku przeglądarki.
  • action to adres trasy plan.store. Samą trasę dopiszesz w podrozdziale 3. Do tego czasu wysłanie formularza zgłosi brak trasy. Tak ma być, dopóki trasy nie ma.
  • name przy select i input jest kluczem, po którym kontroler czyta wartość. Bez name pole nie dociera.
  • @foreach buduje opcje z tej samej tablicy co karty. value to slug, tekst opcji to nazwa.
  • @error('data') wypisuje komunikat tylko wtedy, gdy walidacja odrzuciła pole data. W zmiennej $message jest zdanie z kontrolera. To samo przy slugu i notatce.
@selected i old() @selected dopisuje atrybut selected, gdy warunek jest prawdziwy. Znasz ten mechanizm z @checked na stronie składni. old('slug') oddaje wartość z poprzedniego, nieudanego wysłania. Po błędzie zostaje wybrany ten sam szczyt, a data i notatka wracają do pól value. Przy pierwszej wizycie old() nie ma czego oddać i pola są puste.

2. CSRF

CSRF Cross-Site Request Forgery — obca strona próbuje wysłać formularz do Twojej aplikacji, korzystając z tego, że przeglądarka dołącza ciasteczko sesji. Pole @csrf wstawia ukryte pole _token z jednorazową wartością trzymaną w sesji. Laravel porównuje token z formularza z tokenem sesji i odrzuca POST, gdy się nie zgadzają albo gdy pola nie ma (kod 419).

Po dopięciu trasy z następnego podrozdziału zrób próbę: usuń na chwilę @csrf, wyślij formularz, zobacz stronę 419, przywróć dyrektywę. W źródle działającego formularza ma być <input type="hidden" name="_token">. Tego pola nie piszesz ręcznie.

3. Walidacja i old()

Na górze kontrolera, jeśli jeszcze jej nie ma, dopisz use Illuminate\Http\Request;. W routes/web.php dopisz trasę POST pod trasami GET. GET otwiera stronę, POST przyjmuje formularz.

Route::post('/plan', [SzczytController::class, 'zapiszPlan'])->name('plan.store');
public function zapiszPlan(Request $request)
{
    $dane = $request->validate([
        'slug' => ['required', 'string'],
        'data' => ['required', 'date', 'after_or_equal:today'],
        'notatka' => ['nullable', 'string', 'max:200'],
    ], [
        'data.required' => 'Podaj datę wyjścia.',
        'data.after_or_equal' => 'Plan dotyczy dzisiaj albo późniejszego dnia.',
    ]);

    $dozwolone = collect($this->listaSzczytow())->pluck('slug');

    if (! $dozwolone->contains($dane['slug'])) {
        return back()
            ->withInput()
            ->withErrors(['slug' => 'Wybierz szczyt z listy.']);
    }

    $plany = session('plany', []);
    $plany[] = $dane;
    session(['plany' => $plany]);

    return redirect()
        ->route('szczyty.index')
        ->with('status', 'Plan zapisany w sesji.');
}
Komunikaty po polsku Domyślne zdania Laravela są po angielsku. W drugiej tablicy validate podajesz własne, kluczem pole.regula. Reszty komunikatów na kursie nie tłumaczysz pakietem językowym — dopisujesz te, które widać przy tym formularzu.

Reguły z tablicy, po kolei:

ZapisPrzepuszczaOdrzuca
requiredpole, które przyszło i nie jest pustebrak daty
stringtekstwartość, która nie jest tekstem
datedatę, którą da się odczytaćnapis w rodzaju „jutro”, gdyby pole nie było type="date"
after_or_equal:todaydzisiaj albo późniejwczorajszą datę
nullablepuste pole albo tekstnic — pusta notatka jest w porządku
max:200tekst do 200 znakówdłuższą notatkę

validate przy błędzie samo wraca na poprzednią stronę, przenosi komunikaty do @error i wartości do old(). Metoda się w tym miejscu urywa. Nie wołasz wtedy withInput ręcznie.

Slug z listy nie ma jeszcze reguły „musi być jednym z czterech”. Dlatego po validate jest drugie sprawdzenie. collect(...)->pluck('slug') bierze z tablicy samą kolumnę slugów. contains mówi, czy przysłany slug w niej jest. Przy podrzuconej wartości wracasz przez back() — to skrót przekierowania na poprzedni adres — i sam dokładasz withInput() oraz withErrors, bo to już nie jest wyjątek z validate.

Gdy oba sprawdzenia przejdą, do sesji trafia jeden plan. Potem przeglądarka dostaje adres listy, nie odpowiedź POST.

4. Redirect i sesja

Nad listą:

@if (session('status'))
    <p class="komunikat">{{ session('status') }}</p>
@endif

@if (session('plany'))
    <h2>Plany w tej sesji</h2>
    <ul>
        @foreach (session('plany') as $plan)
            <li>
                {{ $plan['slug'] }} — {{ $plan['data'] }}
                @if (! empty($plan['notatka']))
                    — {{ $plan['notatka'] }}
                @endif
            </li>
        @endforeach
    </ul>
@endif
Po sukcesie zawsze redirect Wzorzec ma nazwę post/redirect/get. Przeglądarka po return redirect() robi nowe żądanie GET. Odświeżenie F5 nie wysyła POST drugi raz. Zostawienie użytkownika na odpowiedzi POST kończy się pytaniem „wysłać formularz ponownie?”.

Dwa zapisy w tej metodzie żyją różnie długo:

  • session(['plany' => $plany]) trzyma tablicę do końca sesji. Drugi argument session('plany', []) przy odczycie daje pustą tablicę, gdy klucza jeszcze nie ma.
  • ->with('status', ...) dodaje komunikat tylko do następnego żądania. Taki jednorazowy komunikat nazywa się flash. Po wyświetleniu Laravel go usuwa.

Sprawdź to na liście. Zapisz plan z dzisiejszą datą: widać zielony pasek i jedną pozycję na liście planów. Naciśnij F5. Pasek znika, pozycja zostaje. Dopisz drugi plan z inną datą — lista ma dwa wiersze. Potem wyślij formularz z pustą datą: zostają wpisane pola, pod datą jest „Podaj datę wyjścia.”, a nowy wiersz nie dochodzi. Na końcu wyślij wczorajszą datę i przeczytaj drugi komunikat.

Plany znikną po wyczyszczeniu ciasteczek albo gdy sesja wygaśnie. W blokach 1–2 sesja leży w plikach (SESSION_DRIVER=file w .env), w katalogu storage/framework/sessions. Sesja nie zastępuje bazy. Zapis zdobycia, już przy koncie użytkownika, zrobisz w rozdziale 11.

Commit

Gdy formularz odrzuca pustą datę, a po udanym zapisie wraca na listę:

git add .
git status
git commit -m "Formularz planu wyjścia"

Przed commitem git status nie pokazuje .env.