8

Dzień 8 z 18

Formularze

Opublikowany

Budujemy pełny przepływ zarządzania ofertą: formularz JobType z walidacją, upload logo przez serwis, automatyczny token, motyw Bootstrap 5, ochronę CSRF oraz akcje edycji, podglądu, publikacji i usuwania.

Czego się nauczysz

  • Klasa JobType — typy pól (ChoiceType, EntityType, FileType)
  • Akcja tworzenia i przetwarzanie formularza (handleRequest, isValid)
  • Motyw formularzy Bootstrap 5 i ochrona CSRF
  • Walidacja przez ograniczenia (constraints)
  • Upload logo: niemapowane pole + serwis FileUploader
  • Automatyczny token w lifecycle callback
  • Edycja, podgląd, publikacja i usuwanie (flash, CSRF)
  • Ukrycie nieopublikowanych ofert (filtr activated)

Co zmieniło się w Symfony 8 vs 4.2

  • Motyw bootstrap_5_layout zamiast bootstrap_3_horizontal
  • Niemapowane pole + FileUploader zamiast hacka string↔File w encji
  • Token w lifecycle callback zamiast osobnego nasłuchiwacza Doctrine
  • Przekazywanie obiektu formularza do widoku (bez createView())

Dzień 8: Formularze

Prawie każda strona ma formularze — od prostego kontaktu po złożone z wieloma polami. Pisanie ich ręcznie jest żmudne: HTML, walidacja, zapis do bazy, komunikaty błędów, ponowne wypełnianie pól. Symfony ma do tego dedykowany komponent Form. Dziś zbudujemy pełny przepływ dodawania oferty: formularz, walidację, upload logo, generowanie tokenu, edycję, podgląd, publikację i usuwanie.

W Symfony 8 formularz to osobna klasa (JobType). Upload pliku realizujemy niemapowanym polem FileType + serwisem FileUploader (encja przechowuje tylko nazwę pliku), a ochrona CSRF działa automatycznie.


Klasa formularza JobType

Formularze definiujemy w osobnych klasach w katalogu src/Form/. Wygeneruj szkielet:

docker compose exec app php bin/console make:form JobType Job

Najpierw dodajmy do encji Job stałe z dozwolonymi typami zatrudnienia (src/Entity/Job.php):

class Job
{
    public const string FULL_TIME = 'full-time';
    public const string PART_TIME = 'part-time';
    public const string FREELANCE = 'freelance';

    public const array TYPES = [self::FULL_TIME, self::PART_TIME, self::FREELANCE];

    // ...
}

Teraz src/Form/JobType.php. Pole logo jest niemapowane (mapped: false) — plik obsłużymy osobno, a w encji zostaje tylko nazwa. Pomijamy pola token i activated (ustawiane automatycznie):

<?php

namespace App\Form;

use App\Entity\Category;
use App\Entity\Job;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Image;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;

class JobType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('type', ChoiceType::class, [
                'choices' => array_combine(Job::TYPES, Job::TYPES),
                'expanded' => true,
                'constraints' => [new NotBlank()],
            ])
            ->add('company', TextType::class, [
                'constraints' => [new NotBlank(), new Length(max: 255)],
            ])
            ->add('logo', FileType::class, [
                'label' => 'Logo',
                'mapped' => false,
                'required' => false,
                'constraints' => [new Image(maxSize: '2M')],
            ])
            ->add('url', UrlType::class, [
                'required' => false,
                'default_protocol' => 'https',
                'constraints' => [new Length(max: 255)],
            ])
            ->add('position', TextType::class, [
                'constraints' => [new NotBlank(), new Length(max: 255)],
            ])
            ->add('location', TextType::class, [
                'constraints' => [new NotBlank(), new Length(max: 255)],
            ])
            ->add('description', TextareaType::class, [
                'constraints' => [new NotBlank()],
            ])
            ->add('howToApply', TextType::class, [
                'label' => 'Jak aplikować?',
                'constraints' => [new NotBlank()],
            ])
            ->add('public', ChoiceType::class, [
                'label' => 'Publiczna?',
                'choices' => ['Tak' => true, 'Nie' => false],
                'constraints' => [new NotNull()],
            ])
            ->add('email', EmailType::class, [
                'constraints' => [new NotBlank(), new Email()],
            ])
            ->add('category', EntityType::class, [
                'class' => Category::class,
                'choice_label' => 'name',
                'constraints' => [new NotBlank()],
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Job::class]);
    }
}

Kilka typów pól: TextType (<input type="text">), EmailType, UrlType, TextareaType, ChoiceType (lista wyboru / radio przy expanded: true) oraz EntityType — specjalny ChoiceType budujący opcje z encji (choice_label: 'name' pokazuje nazwę kategorii).

Zmiana vs Symfony 4.2: metody mają typy zwracane : void, opcje ograniczeń podajemy zwięźle (new Length(max: 255) z named arguments PHP 8), a przycisk „Zapisz” dodamy w szablonie (nie przez SubmitType) — formularz jest wtedy bardziej reużywalny.


Akcja tworzenia oferty i przetwarzanie

Formularz budujemy, renderujemy i przetwarzamy w jednej akcji. Dodaj do JobController:

use App\Entity\Job;
use App\Form\JobType;
use App\Service\FileUploader;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\File\UploadedFile;

#[Route('/job/create', name: 'job_create', methods: ['GET', 'POST'])]
public function create(Request $request, EntityManagerInterface $em, FileUploader $fileUploader): Response
{
    $job = new Job();
    $form = $this->createForm(JobType::class, $job);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $logoFile = $form->get('logo')->getData();
        if ($logoFile instanceof UploadedFile) {
            $job->setLogo($fileUploader->upload($logoFile));
        }

        $job->setActivated(false); // publikacja przez osobną akcję
        $em->persist($job);
        $em->flush();

        return $this->redirectToRoute('job_preview', ['token' => $job->getToken()]);
    }

    return $this->render('job/create.html.twig', [
        'form' => $form,
    ]);
}
  • handleRequest() mapuje dane żądania na formularz,
  • isSubmitted() — czy formularz wysłano (przy GET jest false, pokazujemy pusty formularz),
  • isValid() — czy spełniono wszystkie ograniczenia,
  • redirectToRoute() po zapisie zapobiega ponownemu wysłaniu formularza (odświeżenie strony).

Zmiana vs Symfony 4.2: do szablonu przekazujemy obiekt formularza ('form' => $form), a nie $form->createView() — Symfony 6.2+ robi to automatycznie. Zależności (Request, EntityManagerInterface, FileUploader) wstrzykujemy w argumentach akcji.


Szablon formularza i motyw Bootstrap 5

Utwórz templates/job/create.html.twig:

{% extends 'base.html.twig' %}

{% block title %}Jobeet — dodaj ofertę{% endblock %}

{% block body %}
    <h1 class="h3 mb-4">Dodaj ofertę</h1>

    {{ form_start(form) }}
        {{ form_widget(form) }}
        <button type="submit" class="btn btn-primary mt-3">Zapisz</button>
    {{ form_end(form) }}
{% endblock %}
  • form_start — tag <form> z metodą i (dla plików) enctype,
  • form_widget — wszystkie pola, etykiety i komunikaty błędów,
  • form_end — zamknięcie formularza i ukryte pole tokenu CSRF.

Aby formularz wyglądał jak reszta strony, włącz motyw Bootstrap 5 w config/packages/twig.yaml:

twig:
    form_themes: ['bootstrap_5_layout.html.twig']

Podłącz też przycisk „Dodaj ofertę” w templates/base.html.twig:

<a href="{{ path('job_create') }}" class="btn btn-outline-light ms-auto">Dodaj ofertę</a>

Zmiana vs Symfony 4.2: używamy motywu bootstrap_5_layout.html.twig zamiast bootstrap_3_horizontal_layout.html.twig. Ochrona CSRF jest wbudowana — form_end dodaje ukryte pole tokenu, a Symfony weryfikuje je automatycznie.


Walidacja

Reguły walidacji w Symfony to ograniczenia (constraints). Umieściliśmy je bezpośrednio w formularzu ('constraints' => [...]), dzięki czemu encja pozostaje czysta. Najczęstsze: NotBlank, Length, Email, Image, NotNull.

Uwaga na różnicę required vs NotBlank:

  • required (domyślnie true) dodaje tylko atrybut HTML required — łatwo go obejść w narzędziach przeglądarki,
  • NotBlank waliduje po stronie serwera i obejść się nie da.

Aby przetestować walidację serwerową, wyłącz walidację przeglądarki:

{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}

Dla pól Tak/Nie używamy NotNull (nie NotBlank) — bo false przy NotBlank dałoby fałszywy błąd.

Po wysłaniu formularza z błędnymi danymi Symfony wyświetli komunikaty przy odpowiednich polach:

Błąd walidacji: zbyt długa wartość pola

Zmiana vs Symfony 4.2: ograniczenia można też deklarować atrybutami na encji (#[Assert\NotBlank]) zamiast w formularzu — to kwestia preferencji. W tym rozdziale trzymamy je w formularzu.


Upload logo — serwis FileUploader

Pole logo jest niemapowane, więc plik obsługujemy sami. Zamiast wpisywać logikę do kontrolera, tworzymy serwis. Najpierw parametry w config/services.yaml:

parameters:
    jobs_directory: '%kernel.project_dir%/public/uploads/jobs'

services:
    App\Service\FileUploader:
        arguments:
            $targetDirectory: '%jobs_directory%'

Serwis src/Service/FileUploader.php:

<?php

namespace App\Service;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;

class FileUploader
{
    public function __construct(
        private readonly string $targetDirectory,
        private readonly SluggerInterface $slugger,
    ) {}

    public function upload(UploadedFile $file): string
    {
        $original = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
        $safeName = $this->slugger->slug($original)->lower();
        $fileName = sprintf('%s-%s.%s', $safeName, uniqid(), $file->guessExtension());

        $file->move($this->targetDirectory, $fileName);

        return $fileName;
    }
}

Serwis wstrzykujemy już w akcji create() (wyżej) i zapisujemy zwróconą nazwę pliku w encji. Wyświetl logo w templates/job/show.html.twig — encja przechowuje samą nazwę, więc budujemy ścieżkę filtrem asset():

{% if job.logo %}
    <img src="{{ asset('uploads/jobs/' ~ job.logo) }}" alt="{{ job.company }}" class="mb-3" style="max-height:100px">
{% endif %}

Zmiana vs Symfony 4.2: oryginał przechowywał w encji obiekt UploadedFile/File i konwertował go na string tam i z powrotem przez nasłuchiwacze Doctrine (postLoad, preUpdate) — kruchy hack. Dziś encja trzyma tylko nazwę pliku (string), a upload obsługuje serwis. W większych projektach warto rozważyć VichUploaderBundle, który automatyzuje ten wzorzec.


Automatyczny token

Token oferty (do edycji przez URL) generujemy automatycznie — nie ufamy użytkownikowi. Rozszerz istniejący lifecycle callback w src/Entity/Job.php:

#[ORM\PrePersist]
public function setTimestampsOnCreate(): void
{
    $now = new \DateTimeImmutable();
    $this->createdAt = $now;
    $this->updatedAt = $now;

    if (null === $this->expiresAt) {
        $this->expiresAt = $now->modify('+30 days');
    }

    if (null === $this->token) {
        $this->token = bin2hex(random_bytes(10));
    }
}

Zmiana vs Symfony 4.2: oryginał generował token osobnym nasłuchiwaczem Doctrine (JobTokenListener + wpis w services.yaml). My korzystamy z istniejącego callbacku #[ORM\PrePersist] — mniej konfiguracji. Token tworzymy funkcją random_bytes() (kryptograficznie bezpieczną).


Edycja oferty

Zgodnie z historią F5, ofertę można edytować, znając jej token. Dodaj akcję do JobController:

#[Route('/job/{token}/edit', name: 'job_edit', methods: ['GET', 'POST'], requirements: ['token' => '\w+'])]
public function edit(Request $request, Job $job, EntityManagerInterface $em, FileUploader $fileUploader): Response
{
    $form = $this->createForm(JobType::class, $job);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $logoFile = $form->get('logo')->getData();
        if ($logoFile instanceof UploadedFile) {
            $job->setLogo($fileUploader->upload($logoFile));
        }

        $em->flush(); // obiekt już istnieje — bez persist()

        return $this->redirectToRoute('job_preview', ['token' => $job->getToken()]);
    }

    return $this->render('job/edit.html.twig', ['form' => $form]);
}

Symfony samo wczyta ofertę: parametr trasy {token} odpowiada polu token encji, więc EntityValueResolver wykona findOneBy(['token' => ...]). Szablon templates/job/edit.html.twig jest niemal taki sam jak create.html.twig (zmień tytuł i etykietę przycisku na „Zapisz zmiany”).

Kolejność tras ma znaczenie: zdefiniuj job_create (/job/create) i job_show (/job/{id} z \d+) przed trasami z {token} (\w+), aby /job/create i /job/1 trafiały do właściwych akcji.


Strona podglądu i panel sterowania

Podgląd to ta sama strona co szczegóły oferty, ale dostępna przez token i z paskiem administracyjnym. Dodaj akcję (na końcu, po trasach z {id}):

#[Route('/job/{token}', name: 'job_preview', methods: ['GET'], requirements: ['token' => '\w+'])]
public function preview(Job $job): Response
{
    return $this->render('job/show.html.twig', [
        'job' => $job,
        'hasControlAccess' => true,
        'deleteForm' => $this->createDeleteForm($job),
        'publishForm' => $this->createPublishForm($job),
        'extendForm' => $this->createExtendForm($job),
    ]);
}

W templates/job/show.html.twig na początku bloku body dołącz panel i wyświetl komunikaty flash:

{% for message in app.flashes('success') %}
    <div class="alert alert-success">{{ message }}</div>
{% endfor %}

{% if hasControlAccess is defined and hasControlAccess %}
    {% include 'job/_control_panel.html.twig' with {
        job: job, deleteForm: deleteForm, publishForm: publishForm, extendForm: extendForm
    } only %}
{% endif %}

Utwórz templates/job/_control_panel.html.twig (Bootstrap 5):

<div class="alert alert-secondary d-flex flex-wrap gap-2 align-items-center">
    <strong>Panel sterowania:</strong>

    {% if job.activated %}
        {% if job.expiresAt < date() %}
            <span class="badge bg-danger">Wygasła</span>
        {% else %}
            <span>Wygasa za <strong>{{ job.expiresAt.diff(date()).days }}</strong> dni</span>

            {% if job.expiresAt.diff(date()).days < 5 %}
                {{ form_start(extendForm, {attr: {class: 'd-inline'}}) }}
                    <button type="submit" class="btn btn-sm btn-outline-primary">
                        <i class="fa-solid fa-arrows-rotate me-1"></i>Przedłuż (o 30 dni)
                    </button>
                {{ form_end(extendForm) }}
            {% endif %}
        {% endif %}
    {% else %}
        <a class="btn btn-sm btn-outline-secondary" href="{{ path('job_edit', {token: job.token}) }}">
            <i class="fa-solid fa-pen me-1"></i>Edytuj
        </a>

        {{ form_start(publishForm, {attr: {class: 'd-inline'}}) }}
            <button type="submit" class="btn btn-sm btn-success">
                <i class="fa-solid fa-check me-1"></i>Publikuj
            </button>
        {{ form_end(publishForm) }}
    {% endif %}

    {{ form_start(deleteForm, {attr: {class: 'd-inline'}}) }}
        <button type="submit" class="btn btn-sm btn-outline-danger"
                onclick="return confirm('Na pewno usunąć?')">Usuń</button>
    {{ form_end(deleteForm) }}

    <span class="ms-auto small text-muted">
        Zapisz ten <a href="{{ url('job_preview', {token: job.token}) }}">adres</a>, aby zarządzać ofertą.
    </span>
</div>

date() to funkcja Twiga zwracająca bieżącą datę, a .diff(date()).days liczy pozostałe dni. Użyliśmy url() (adres absolutny) — do zapisania i udostępnienia.

Panel pokazuje różne akcje zależnie od stanu oferty. Dla opublikowanej oferty widać czas do wygaśnięcia, a gdy zostało mniej niż 5 dni — przycisk przedłużenia (reguła F5):

Panel sterowania: oferta aktywna, wygasa za 4 dni

Dla oferty jeszcze nieopublikowanej widać przyciski Edytuj i Publikuj:

Panel sterowania: oferta nieopublikowana


Usuwanie i publikacja

Oba działania to formularze (bezpieczne, z tokenem CSRF, metody DELETE/POST). Dodaj metody pomocnicze i akcje do JobController:

use Symfony\Component\Form\FormInterface;

private function createDeleteForm(Job $job): FormInterface
{
    return $this->createFormBuilder()
        ->setAction($this->generateUrl('job_delete', ['token' => $job->getToken()]))
        ->setMethod('DELETE')
        ->getForm();
}

private function createPublishForm(Job $job): FormInterface
{
    return $this->createFormBuilder()
        ->setAction($this->generateUrl('job_publish', ['token' => $job->getToken()]))
        ->setMethod('POST')
        ->getForm();
}

private function createExtendForm(Job $job): FormInterface
{
    return $this->createFormBuilder()
        ->setAction($this->generateUrl('job_extend', ['token' => $job->getToken()]))
        ->setMethod('POST')
        ->getForm();
}

#[Route('/job/{token}/delete', name: 'job_delete', methods: ['DELETE'], requirements: ['token' => '\w+'])]
public function delete(Request $request, Job $job, EntityManagerInterface $em): Response
{
    $form = $this->createDeleteForm($job);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $em->remove($job);
        $em->flush();
    }

    return $this->redirectToRoute('job_list');
}

#[Route('/job/{token}/publish', name: 'job_publish', methods: ['POST'], requirements: ['token' => '\w+'])]
public function publish(Request $request, Job $job, EntityManagerInterface $em): Response
{
    $form = $this->createPublishForm($job);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $job->setActivated(true);
        $em->flush();

        $this->addFlash('success', 'Oferta została opublikowana.');
    }

    return $this->redirectToRoute('job_preview', ['token' => $job->getToken()]);
}

#[Route('/job/{token}/extend', name: 'job_extend', methods: ['POST'], requirements: ['token' => '\w+'])]
public function extend(Request $request, Job $job, EntityManagerInterface $em): Response
{
    $form = $this->createExtendForm($job);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $job->setExpiresAt((new \DateTimeImmutable())->modify('+30 days'));
        $em->flush();

        $this->addFlash('success', 'Ważność oferty przedłużono o 30 dni.');
    }

    return $this->redirectToRoute('job_preview', ['token' => $job->getToken()]);
}

addFlash() zapisuje jednorazowy komunikat w sesji — po wyświetleniu znika (po odświeżeniu już go nie ma).

Zmiana vs Symfony 4.2: przyciski to <button> w motywie Bootstrap 5 (bez glyphiconów), a token CSRF generuje się automatycznie w formularzach createFormBuilder().


Ukrycie nieopublikowanych ofert

Nieaktywne oferty nie mogą być widoczne na stronie głównej ani dostępne przez URL. Uzupełnij metody repozytoriów o warunek activated = true. W JobRepository:

->andWhere('j.activated = :activated')
->setParameter('activated', true)

Dodaj go do findActiveJobs(), findActiveJob() oraz getActiveJobsByCategoryQuery() (z Dnia 7). To samo w CategoryRepository::findWithActiveJobs(). Zaktualizuj też Category::getActiveJobs():

public function getActiveJobs(): Collection
{
    $now = new \DateTimeImmutable();

    return $this->jobs->filter(
        fn (Job $job): bool => $job->getExpiresAt() > $now && $job->isActivated()
    );
}

Teraz świeżo dodana oferta jest widoczna tylko przez swój adres z tokenem (podgląd z panelem), a na stronę główną trafia dopiero po publikacji.


Podsumowanie

Mamy kompletny przepływ zarządzania ofertą:

  • ✅ formularz JobType z typami pól (ChoiceType, EntityType, FileType) i walidacją (constraints),
  • ✅ motyw formularzy Bootstrap 5 i wbudowana ochrona CSRF,
  • ✅ upload logo przez niemapowane pole + serwis FileUploader (encja trzyma tylko nazwę pliku),
  • ✅ automatyczny token w lifecycle callback,
  • ✅ akcje: tworzenie, edycja (po tokenie), podgląd, publikacja, przedłużenie i usuwanie z flash,
  • ✅ ukrycie nieopublikowanych ofert (filtr activated).

W Dniu 9 stworzymy komendy konsolowe (#[AsCommand]) — m.in. do usuwania przeterminowanych ofert, i poznamy harmonogram (symfony/scheduler).

Narzędzia / paczki

symfony/form symfony/validator FileUploader Bootstrap 5

Spis treści