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 polemFileType+ serwisemFileUploader(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 przezSubmitType) — 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 (przyGETjestfalse, 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.twigzamiastbootstrap_3_horizontal_layout.html.twig. Ochrona CSRF jest wbudowana —form_enddodaje 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ślnietrue) dodaje tylko atrybut HTMLrequired— łatwo go obejść w narzędziach przeglądarki,NotBlankwaliduje 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:

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/Filei 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 wservices.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) ijob_show(/job/{id}z\d+) przed trasami z{token}(\w+), aby/job/createi/job/1trafiał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):

Dla oferty jeszcze nieopublikowanej widać przyciski Edytuj i Publikuj:

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 formularzachcreateFormBuilder().
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
JobTypez 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).