Szkolenie
Programowanie aplikacji internetowych w oparciu o framework Symfony
Symfony 7.4 • Docker Compose • PostgreSQL 18.3 • Twig • Doctrine • API Platform • PHPUnit • PHPStan • PHP CS Fixer
O mnie
Leszek Prabucki
PHP/Angular Tech Leader & Solutions Architect
- Od 2007 rozwijam aplikacje webowe i pomaga modernizować istniejące systemy.
- Specjalizacja: Symfony, architektura aplikacji, jakość kodu i praktyki pracy zespołów.
- Pracuje z podejściami takimi jak Event Storming, BDD i Impact Mapping; mentoruje zespoły techniczne
- Open source i społeczność: wkład w ekosystem Symfony, Composer, Doctrine, SymfonyDocs oraz aktywność konferencyjna i meetupowa.

Start projektu
git clone https://github.com/l3l0/symfony-traning-platform-workshop.gitcd symfony-traning-platform-workshop make
makebuduje obraz, stawia kontenery i wykonuje podstawowy bootstrap projektu.- Po starcie aplikacja jest dostępna pod
http://localhost:8080. - Po starcie bazy można odtworzyć dane przez
make db-init.
Blok 1
Od HTTP do pierwszej aplikacji Symfony
Jedna narastająca aplikacja: od lokalnego runtime do pierwszego web flow katalogu szkoleń i certyfikacji.
Cel bloku
- Wprowadzenie do HTTP oraz tego jak Symfony je obsługuje
- Uruchomić projekt Symfony lokalnie przez Docker Compose i Makefile.
- Zobaczyć przejście od requestu HTTP do kontrolera i widoku.
- Zamknąć pierwszy checkpoint: strona katalogu i prosty test funkcjonalny.
Jak Symfony obsługuje HTTP?
Jak Symfony obsługuje HTTP?
Jak Symfony obsługuje HTTP?
Jakie komponenty Symfony biorą udział w obsłudze HTTP?
- HttpFoundation component - budowanie Requesta oraz Response
- EventDispatcher component - HttpKernel używa tego komponentu do rozpowszechniania informacji o stanie życia Request i Response
- HttpKernel component - Kernel obsługuje cały proces przyjęcia zapytania HTTP i wygenerowania odpowiedzi przy użyciu HttpFoundation oraz EventDispatcher
<?php
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Symfony\Component\HttpKernel\Controller\ControllerResolver;
use Symfony\Component\HttpKernel\HttpKernel;
// create the Request object
$request = Request::createFromGlobals();
$dispatcher = new EventDispatcher();
// ... add some event listeners
// create your controller and argument resolvers
$controllerResolver = new ControllerResolver();
$argumentResolver = new ArgumentResolver();
// instantiate the kernel
$kernel = new HttpKernel($dispatcher, $controllerResolver, new RequestStack(), $argumentResolver);
// actually execute the kernel, which turns the request into a response
// by dispatching events, calling a controller, and returning the response
$response = $kernel->handle($request);
// send the headers and echo the content
$response->send();
// trigger the kernel.terminate event
$kernel->terminate($request, $response);Ważne eventy http kernel
- kernel.request - pierwszy event rzucany przez kernel, tutaj podłączony jest routing component i routing resolver, może na ten event zwracać response (np security z access denided)
- kernel.controller - event rzucany zaraz przed wykonaniem kontrolera, można dodać dodakowy kod który wykona się przed określonymi kontrolerami lub wszystkimi kontrolerami
- kernel.view - wykonywany zaraz po wykonaniu kontrolera z tym co zwraca, dzięki niemu możemy np. mapować to co zwraca kontroler na obiekt Response z HttpFoundation
- kernel.response - wykonywany na końcu przy zbudowanym response, pozwala na nadpisanie albo zmiane response dla części lub wszystkich kontrolerów
- kernel.exception - wykonywany w wypadku tego gdy złapany jest wyjątek w czasie procesowania requestu
To nie wszystkie eventy ale najważniejsze więcej możesz się dowiedzieć z dokumentacji: https://symfony.com/doc/current/components/http_kernel.html#creating-an-event-listener
Mapa requestu (uproszczona do ćwiczenia)
HTTP request -> routing -> controller -> Twig -> HTTP response
- Routing wskazuje akcję kontrolera.
- Kontroler przygotowuje dane dla widoku.
- Twig renderuje pierwszą stronę katalogu.
- Profiler pozwala zobaczyć, co faktycznie wydarzyło się w runtime.
Live coding checkpoint
make
make about
make health
make test- Bootstrap lokalnego środowiska.
- Pierwsza strona katalogu pod
/. - Pierwszy test funkcjonalny WebTestCase.
Ćwiczenie
- Uruchom projekt lokalnie.
- Napisz test który sprawdzi że nowa przewidywana ścieżka szkolenia istnieje i jest widoczna na stronie
- Rozszerz katalog o tą dodatkową ścieżkę szkoleniową albo zmień treść jednej karty.
- Uruchom test i sprawdź, czy strona dalej przechodzi checkpoint.
Done definition: aplikacja działa lokalnie, / odpowiada 200 i test przechodzi.
Blok 2
Routing, kontrolery, usługi i Twig
Rozszerzamy katalog do przepływu lista -> szczegóły i porządkujemy odpowiedzialności między routingiem, kontrolerem, usługą i widokiem.
Cel bloku
- Zbudować przepływ
lista -> szczegóły. - Oddzielić HTTP od logiki aplikacyjnej.
- Użyć routingu, kontrolera, usługi i Twig jako jednego łańcucha.
- Zamknąć blok testem
200/404.
Od URL do kontrolera
- Route mapuje URL na konkretną akcję kontrolera.
- Parametry trasy, np.
{slug}, stają się wejściem do akcji. - Nazwa trasy daje stabilny sposób generowania linków.
- Routing jest kontraktem wejścia do warstwy HTTP.
Routing w praktyce Symfony
- Atrybuty
#[Route(...)]spinają URL, nazwę trasy i metody HTTP. path()w Twig generuje URL na podstawie nazwy trasy, a nie ręcznie sklejonego stringa.debug:routerpozwala szybko sprawdzić, co naprawdę wystawiamy.404jest naturalnym efektem braku dopasowania albo braku zasobu.
Kontroler jako cienka warstwa HTTP
- Kontroler przyjmuje dane z requestu i przygotowuje odpowiedź.
- Nie powinien przejmować odpowiedzialności za logikę domenową.
- Dobry kontroler deleguje pracę do usługi i zwraca
Response. - Dzięki temu kod łatwiej testować i rozwijać.
- Do generowania kontrolerów można użyć Symfony Maker https://symfony.com/bundles/SymfonyMakerBundle/current/index.html
Kontroler jako cienka warstwa HTTP
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Catalog\LearningPathCatalog;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class CatalogController extends AbstractController
{
#[Route('/', name: 'app_catalog_index', methods: ['GET'])]
public function index(LearningPathCatalog $catalog): Response
{
return $this->render('catalog/index.html.twig', [
'learningPaths' => $catalog->all(),
]);
}
}
Usługa aplikacyjna i autowiring
- LearningPathCatalog trzyma logikę pobierania danych dla flow katalogu.
- Autowiring wstrzykuje usługę do kontrolera przez typ argumentu albo konstruktor.
- Usługa pozwala wymienić źródło danych bez przepisywania warstwy HTTP.
- To przygotowuje grunt pod przejście na Doctrine w kolejnym dniu.
Twig jako warstwa prezentacji
{% extends 'base.html.twig' %}
{% block title %}Certification Platform | Catalog{% endblock %}
{% block body %}
<main class="page">
<section class="hero">
<div class="eyebrow">Certification and Education Platform</div>
<h1>Certification journeys for teams and professionals</h1>
<p>
Explore instructor-led training, certification preparation, and renewal programs
delivered through one consistent learning platform.
</p>
</section>
<section class="catalog" aria-label="Learning paths">
{% for learningPath in learningPaths %}
{# ... #}
{% endfor %}
</section>
</main>
{% endblock %}Twig jako warstwa prezentacji
extendsi layout bazowy porządkują wspólny szkielet aplikacji.- Twig renderuje dane przekazane przez kontroler, ale nie podejmuje decyzji biznesowych.
path()służy do bezpiecznego linkowania między ekranami.- Domyślny escaping pomaga ograniczyć klasę prostych błędów w widokach.
Mapowanie na nasz projekt
/pokazuje listę ścieżek szkoleniowych./catalog/{slug}pokazuje szczegóły konkretnego kursu.- Kontroler pobiera dane z katalogu i przekazuje je do Twig.
- Brakujący
slugkończy się404, co też testujemy.
Live coding checkpoint
GET /
GET /catalog/symfony-foundations
GET /catalog/not-existing
bin/console debug:router
make test- Lista odpowiada
200. - Szczegóły odpowiadają
200. - Nieistniejący slug daje
404.
Ćwiczenie
- Dodaj nową ścieżkę do katalogu i upewnij się, że ma działający
slug. - Rozszerz widok szczegółów o kolejne pole lub sekcję.
- Dopisz albo popraw test dla nowej trasy albo przypadku
404.
Done definition: lista i szczegóły działają, routing jest czytelny, a testy funkcjonalne przechodzą.
Blok 3
Doctrine, PostgreSQL i pierwszy trwały model
Przechodzimy z danych in-memory do bazy i porządkujemy model aplikacji tak, żeby kolejne kroki MVP miały trwały fundament.
Cel bloku
- Przenieść katalog z danych in-memory do PostgreSQL przez Doctrine ORM.
- Wprowadzić encję
Course, repozytorium, migrację i fixtures. - Zostawić działający katalog oparty o bazę oraz powtarzalny reset środowiska.
Dlaczego teraz baza danych
- Dane in-memory wystarczają na start, ale nie na rozwój realnego MVP.
- Kolejne kroki, takie jak formularze, security i API, potrzebują trwałego modelu.
- Baza daje powtarzalny stan aplikacji i sensowny kontrakt dla zespołu oraz testów.
Model domeny naszego MVP
- Dziś utrwalamy
Coursejako pierwszy trwały element modelu. - W kolejnych blokach dojdą
Enrollment,ExamAttemptiCertificate. - Już na tym etapie warto pokazać, że aplikacja rośnie wokół modelu, a nie wokół pojedynczego kontrolera.
Encja Doctrine w praktyce
- Encja to klasa domenowa z mapowaniem ORM opartym o atrybuty.
- Pola i typy w klasie stają się kontraktem dla tabeli i kolumn.
- Encja nie powinna znać HTTP, Twig ani szczegółów routingu.
Encja Doctrine w praktyce - mapping
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\CourseRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CourseRepository::class)]
final class Course
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64, unique: true)]
private string $slug;
}https://www.doctrine-project.org/projects/doctrine-orm/en/2.20/reference/attributes-reference.html#attributes-reference
Repozytorium jako granica odczytu
- Kontroler nie powinien składać zapytań bezpośrednio.
CourseRepositoryodpowiada za pobranie danych z bazy.CourseCatalogzostaje cienką usługą aplikacyjną nad repozytorium.- Taki podział upraszcza testy i zmianę źródła danych.
Migracje jako historia schematu
- Zmiana modelu to nie tylko kod, ale też jawna zmiana schematu bazy.
doctrine:migrations:diffgeneruje historię tej zmiany.doctrine:migrations:migrateodtwarza schemat w przewidywalny sposób na każdym środowisku.
Fixtures jako stan startowy
- Fixtures dostarczają dane demonstracyjne i testowe dla wspólnego punktu startowego.
- Nie mieszamy ich z migracjami: migracje opisują strukturę, fixtures opisują dane.
- W szkoleniu przyspieszają live coding i reset ćwiczeń między uczestnikami.
Mapowanie na nasz projekt
Coursejest pierwszą encją domenową.CourseRepositoryprzejmuje odczyt danych.CourseCatalogpozostaje warstwą aplikacyjną dla flow katalogu.AppFixturesimake db-initdają powtarzalny stan roboczy.
Live coding checkpoint
make db-init
make test
make console ARGS='doctrine:schema:validate'
GET /
GET /catalog/symfony-foundations- Migracja tworzy i aktualizuje schemat bazy.
- Fixtures ładują katalog kursów do PostgreSQL.
- Lista i szczegóły nadal przechodzą testy po przejściu na bazę.
Ćwiczenie
- Dodaj nowy kurs do fixtures i upewnij się, że pojawia się na liście oraz w szczegółach.
- W wersji rozszerzonej dodaj nowe pole do
Course, wygeneruj migrację i pokaż je w widoku. - Na końcu odtwórz stan przez
make db-initi potwierdź wynik przezmake test.
Done definition: katalog czyta dane z PostgreSQL, migracje są odtwarzalne, a testy przechodzą bez ręcznych kroków.
Blok 4
Formularze, walidacja, CSRF i pierwszy write flow
Budujemy pierwszy pełny proces biznesowy: od formularza uczestnika do wyniku egzaminu i certyfikatu widocznego na stronie podsumowania.
Cel bloku
- Dodać pierwszy write flow do aplikacji bez rozbijania istniejącego katalogu.
- Pokazać praktyczny kontrakt Symfony Forms:
createForm(),handleRequest(),isSubmitted(),isValid(). - Domknąć happy path: zapis, wynik egzaminu i certyfikat na stronie summary.
Dlaczego ten blok jest ważny
- To pierwszy stanowo zmieniający proces biznesowy w aplikacji.
- Łączy warstwę HTTP, walidację, bazę danych i redirect w jeden spójny flow.
- Pokazuje nie tylko happy path, ale też to, jak Symfony prowadzi użytkownika po błędzie.
Standardowy cykl formularza Symfony
createForm()buduje kontrakt formularza dla obiektu wejściowego.handleRequest()mapuje dane requestu i oznacza formularz jako submitted.isSubmitted()iisValid()rozdzielają stan formularza od poprawności danych.- Ten sam widok może obsłużyć stan początkowy, błędy walidacji i poprawne submit.
DTO czy encja
- Formularz oparty o DTO trzyma warstwę HTTP z dala od encji trwałych.
- Encje powstają dopiero po walidacji, w workflow aplikacyjnym.
- To ogranicza przypadkowy zapis niepełnych danych i upraszcza testy.
- W naszym MVP
CourseEnrollmentInputprowadzi doEnrollment,ExamAttemptiCertificate.
Walidacja i atrybuty
- Atrybuty
Assert*deklarują reguły blisko obiektu wejściowego. - Ta sama walidacja wspiera UI, testy i późniejsze refaktoryzacje.
- Niepoprawne dane wracają do formularza z błędami i kodem odpowiedzi
422. - Reguły biznesowe związane z tworzeniem encji zostają po stronie workflow.
CSRF i kiedy ma znaczenie
- Czym jest atak CSRF (Cross-Site Request Forgery) https://gdata.pl/czym-jest-atak-csrf
- Formularze Symfony mają domyślną ochronę CSRF dla operacji zmieniających stan.
- GET nie powinien mutować danych, więc nie jest miejscem dla CSRF.
- Brakujący albo błędny token zatrzymuje submit przed zapisaniem danych.
- CSRF nie zastępuje uwierzytelnienia ani autoryzacji.
POST, redirect i summary
- Po poprawnym zapisie stosujemy wzorzec POST-redirect-GET.
- Odświeżenie strony nie powoduje ponownego wysłania formularza.
- Summary działa pod
/enrollments/{token}, więc URL nie ujawnia wewnętrznego ID. - Testy pilnują zarówno redirectu, jak i ścieżki z błędem walidacji.
Mapowanie na nasz projekt
GET/POST /catalog/{slug}/enrollobsługuje ekran i submit formularza.CourseEnrollmentInputiCourseEnrollmentTypedefiniują wejście użytkownika.CourseEnrollmentWorkflowzapisujeEnrollment,ExamAttemptiCertificate./enrollments/{token}pokazuje wynik procesu w bezpieczniejszym kontrakcie URL.
Live coding checkpoint
GET /catalog/symfony-foundations/enroll
POST /catalog/symfony-foundations/enroll
GET /enrollments/{token}
make test- Pusty lub błędny formularz wraca z widocznymi błędami.
- Poprawny submit kończy się redirectem na summary.
- Flow pozostaje pokryty testami funkcjonalnymi.
Ćwiczenie
- Tag: checkpoint/day2-block4
- Dodaj nowe pole do formularza i walidację atrybutem
Assert*. - Wyświetl nową wartość na stronie podsumowania bez przenoszenia logiki do kontrolera.
- Dopisz albo popraw test dla walidacji albo happy path.
Done definition: formularz zapisuje dane, błędy są czytelne, a summary pokazuje kompletny wynik procesu.
Blok 5
Twig Components i UX Autocomplete
Najpierw wyciągamy powtarzalny markup do małego komponentu Twig, a potem dokładamy krótki przykład async search przez Symfony UX Autocomplete.
Po co komponent w Twig?
- Powtarzalny markup karty kursu znika z widoku listy.
- Komponent ma jawne dane wejściowe: propsy.
- Widok strony opisuje układ, a szczegóły renderowania siedzą w małej jednostce.
- Nie wchodzimy w Live Components; to nadal server-side Twig.
Minimalny komponent anonimowy
{# templates/components/CourseCard.html.twig #}
{% props course, ctaLabel = 'View details' %}
<article {{ attributes.defaults({class: 'card'}) }} data-course-card>
<h2>{{ course.title }}</h2>
<a href="{{ path('app_catalog_show', {slug: course.slug}) }}">
{{ ctaLabel }}
</a>
</article>Checkpoint i fallback
- W liście katalogu zostaje tylko
<twig:CourseCard :course="course" />. - Sprawdzamy
make testilint:twig templates. - Done: refaktor nie zmienia zachowania strony katalogu.
UX Autocomplete: async search
- Nie robimy lokalnego selecta z pełną listą opcji.
- Tworzymy osobny field type oznaczony
#[AsEntityAutocompleteField]. - Symfony UX wystawia endpoint
/autocomplete/{alias}i zwraca JSON dla Tom Select. - W naszym przykładzie szukamy po encji
Course, bez zmiany workflow zapisu.
Minimalny field type
#[AsEntityAutocompleteField]
final class CourseAutocompleteField extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
"class" => Course::class,
"choice_label" => "title",
"searchable_fields" => ["title", "category", "description"],
"min_characters" => 2,
"preload" => false,
]);
}
public function getParent(): string
{
return BaseEntityAutocompleteType::class;
}
}Ćwiczenie
- Na tagu checkpoint/day2-ux-autocomplete
- Spróbujcie utworzyć swojego autocomplete takiego który przetrzyma więcej niż 1 element (albo zmieńcie istniejący CourseAutocompleteField)
- https://symfony.com/bundles/ux-autocomplete/current/index.html
Blok 6
CollectionType i dynamiczne adresy
Rozszerzamy formularz zapisu o kolekcję adresów szkoleniowych: województwo, miasto zależne od województwa i adres.
Zakres przykładu
- Nie robimy migracji ani relacji
OneToManyw tym bloku. - Adresy są częścią DTO wejściowego
CourseEnrollmentInput. - Statyczny
AddressCatalogdaje listę województw i miast. - Stimulus odpowiada tylko za UI: add/remove i filtrowanie miast.
DTO i statyczny katalog
Walidacja serwerowa nadal pilnuję danych po submit, nawet jeśli ktoś ominie JavaScript.
final class CourseEnrollmentInput
{
/** @var list<TrainingAddressInput> */
#[Assert\Count(min: 1)]
#[Assert\Valid]
public array $trainingAddresses = [];
}
final class TrainingAddressInput
{
#[Assert\NotBlank]
public ?string $province = null;
#[Assert\NotBlank]
public ?string $city = null;
#[Assert\NotBlank]
public ?string $streetAddress = '';
}CollectionType w formularzu
entry_typemówi, jaki formularz ma dostać każdy element kolekcji.allow_addiprototypepozwalają tworzyć nowe wpisy w przeglądarce.delete_emptyusuwa puste, przypadkowo dodane wpisy.
->add('trainingAddresses', CollectionType::class, [
'entry_type' => TrainingAddressType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'delete_empty' => static fn (?TrainingAddressInput $address = null): bool =>
$address === null || $address->isBlank(),
])Prototype + Stimulus
- Prototype zawiera placeholder
__name__. - Stimulus podmienia go na kolejny indeks kolekcji.
- Po zmianie województwa kontroler ukrywa miasta z innych województw.
<section
data-controller="address-collection"
data-address-collection-index-value="{{ nextAddressIndex }}"
data-address-collection-prototype-value="{{ addressPrototype|e('html_attr') }}"
>
<div data-address-collection-target="collection">
{# existing rows #}
</div>
</section>Checkpoint i ćwiczenie
make test
make console ARGS='lint:twig templates'
make console ARGS='debug:asset-map --no-debug'
- Dodaj drugi adres przez UI i sprawdź nazwy pól
[trainingAddresses][1]. - Zmień województwo i zobacz, że lista miast filtruje się w tej samej pozycji kolekcji.
- Dopisz test serwerowy dla błędnej pary województwo-miasto.
- Fallback: jeśli JS się wysypie, submit i walidacja nadal są po stronie Symfony.
Blok 7
Security i jakościowy workflow pracy
Dokładamy logowanie, autoryzację oraz jeden powtarzalny quality gate, który da się odpalić lokalnie i później przenieść do CI.
Cel bloku
- Włączyć logowanie i logout bez nadmiernego rozbudowywania domeny.
- Chronić kluczowe flow oraz dashboard trenera prostym, czytelnym modelem ról.
- Pokazać, że
make checkjest codziennym kontraktem pracy, a nie tylko dodatkiem na koniec.
Mentalny model security
- Provider odpowiada za znalezienie użytkownika.
- Firewall określa punkt wejścia do security dla danej części aplikacji.
- Uwierzytelnienie mówi, kim jest użytkownik; autoryzacja mówi, co może zrobić.
access_controlwiąże ścieżki HTTP z minimalnym wymaganiem dostępu.
Logowanie i chronione flow
form_logindaje prosty, czytelny webowy punkt wejścia do aplikacji.- Anonim trafiający na chronioną trasę jest przekierowywany do
/login. logoutdomyka kontrakt sesji bez dodatkowej logiki kontrolera.- Po zalogowaniu nadal pracujemy na zwykłych kontrolerach i trasach Symfony.
Rola użytkownika a decyzja autoryzacyjna
- Rola to gruby filtr wejściowy, a nie pełna polityka biznesowa.
ROLE_USERiROLE_TRAINERwystarczają do naszego MVP.- Nawet po przejściu przez rolę dalej pilnujemy, jakie dane pokazujemy w widoku.
- Dlatego dashboard trenera jest operacyjny, ale bez PII i kodów certyfikatów.
Quality gate jako codzienny kontrakt
make checkscala styl, analizę statyczną i testy w jeden rytuał pracy.- Jeden punkt wejścia jest wygodniejszy dla uczestnika, review i przyszłego CI.
- Quality gate ma działać szybko i przewidywalnie, inaczej zespół przestaje go używać.
- Ten blok ma zbudować nawyk: sprawdź zmianę przed commitem.
Jak czytać wynik narzędzi
php-cs-fixernormalizuje styl mechanicznie, więc nie warto o nim dyskutować na review.phpstanpokazuje problemy kontraktu i typów, zanim zrobi to runtime.phpunitpilnuje przepływów biznesowych oraz regresji.- Najpierw czytamy pierwszy czerwony sygnał i naprawiamy przyczynę, nie objaw.
Mapowanie na nasz projekt
security.yamldefiniuje provider, firewall iaccess_control./logini/logoutobsługują kontrakt sesji użytkownika.- Enrollment flow wymaga
ROLE_USER, a dashboardROLE_TRAINER. make checkspinaphp-cs-fixer,phpstaniphpunit.
Live coding checkpoint
GET /login
GET /catalog/symfony-foundations/enroll
GET /trainer/dashboard
make check- Anonim jest przekierowany do logowania.
- Zwykły użytkownik przechodzi chroniony enrollment flow.
- Trener widzi tylko dashboard operacyjny, a quality gate pozostaje zielony.
Ćwiczenie
- Zabezpiecz dodatkową trasę i wybierz, czy wymaga
ROLE_USERczyROLE_TRAINER. - Dopisz test na redirect do logowania albo na
403. - Uruchom
make checki zamknij zmianę bez ręcznych kroków.
Done definition: trasa ma jawny kontrakt security, testy go pilnują, a quality gate pozostaje zielony.
Blok 8
REST API, API Platform i OpenAPI
Po domknięciu webowego MVP wystawiamy wąski, czytelny read API i pokazujemy, jak kod generuje dokumentację kontraktu.
Cel bloku
- Dodać API Platform bez rozszerzania scope o write API, JWT i front osobny od Symfony.
- Wystawić dwa zasoby read-only:
CourseiCertificate. - Pokazać, jak OpenAPI i grupy serializacji porządkują kontrakt publiczny.
Dlaczego API dopiero teraz
- Najpierw ustabilizowaliśmy domenę i web flow, więc API ma już sensowny model pod spodem.
- API tworzone zbyt wcześnie często dokumentuje życzenia zamiast działającego systemu.
- Teraz możemy wystawić wąski kontrakt, który wynika z gotowego MVP, a nie z abstrakcji.
ApiResource i operacje
#[ApiResource]oznacza klasę jako publiczny zasób API.- Operacje
GetiGetCollectionjawnie definiują, co wystawiamy. - Dla prostego read API nie potrzebujemy osobnych kontrolerów.
- Ograniczenie do read-only utrzymuje scope bloku w ryzach.
Grupy serializacji
- Grupy serializacji kształtują zewnętrzny kontrakt niezależnie od pełnej encji.
- Nie każde pole bazy powinno trafić do publicznego payloadu.
- Web UI i API mogą mieć różne potrzeby prezentacji tych samych danych.
- Testy API powinny pilnować właśnie publicznych pól, a nie całej encji.
OpenAPI i Swagger UI
- Ten sam kod generuje dokumentację dla ludzi i dla narzędzi.
/api/docssłuży do szybkiej inspekcji przez przeglądarkę./api/docs.jsonopenapidaje kontrakt maszynowy dla integracji i testów.- Dokumentację traktujemy jak zwykły endpoint: też powinna przechodzić smoke test.
Publiczna weryfikacja certyfikatu bez PII
- Zasób certyfikatu wspiera publiczną weryfikację, ale bez ujawniania danych osobowych uczestnika.
- W payloadzie zostają tylko informacje potrzebne do potwierdzenia ważności certyfikatu.
- Granica prywatności jest jawna i zakodowana w grupach serializacji.
- To dobry przykład, że publiczne API nie musi oznaczać pełnej ekspozycji modelu.
Mapowanie na nasz projekt
CourseiCertificatesą wystawione jako zasoby API Platform./api/docsi/api/docs.jsonopenapiopisują ten sam kontrakt.- Kontrakt certyfikatu jest zsanityzowany i bezpieczniejszy prywatnościowo.
- Testy API pilnują endpointów i dokumentacji bez dotykania kontrolerów.
Live coding checkpoint
GET /api/courses
GET /api/certificates
GET /api/docs
GET /api/docs.jsonopenapi
make check- Payload kursów jest publiczny i czytelny.
- Payload certyfikatów pozostaje zsanityzowany i bez PII.
- Swagger UI i spec OpenAPI są generowane z kodu i przechodzą smoke test.
Ćwiczenie
- Dodaj nowe pole tylko do read API kursu albo certyfikatu przez grupy serializacji.
- Sprawdź zmianę w
/apii w/api/docs.jsonopenapi. - Dopisz albo popraw test kontraktu API bez dotykania kontrolerów.
Done definition: endpoint zwraca nowy kontrakt, dokumentacja jest spójna, a make check dalej przechodzi.
Blok 9
Custom Voter i decyzje dostępu na obiektach
Po rolach i API dokładamy precyzyjną autoryzację: nie tylko kto jest zalogowany, ale czy może zobaczyć konkretny zapis.
Rola to za mało
ROLE_USERmówi, że użytkownik przeszedł przez bramkę aplikacji.- Nie mówi, czy może zobaczyć konkretny
Enrollment. - Voter przenosi decyzję z kontrolera do małej klasy polityki dostępu.
- Uczestnik widzi swój zapis, a trener widzi każdy zapis.
Minimalny voter
final class EnrollmentVoter extends Voter
{
public const VIEW = 'ENROLLMENT_VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
return self::VIEW === $attribute && $subject instanceof Enrollment;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token,
?Vote $vote = null,
): bool {
$user = $token->getUser();
return $user instanceof UserInterface
&& ($user->getUserIdentifier() === $subject->getParticipantEmail()
|| in_array('ROLE_TRAINER', $token->getRoleNames(), true));
}
}Rejestracja i użycie
- Przy standardowym
services.yamlvoter jest autokonfigurowany tagiemsecurity.voter. - Kontroler wywołuje
denyAccessUnlessGranted(EnrollmentVoter::VIEW, $enrollment). - Decyzję potwierdzają testy: własny zapis
200, cudzy zapis403, trener200.
Live coding checkpoint
make test
make console ARGS='debug:container --tag=security.voter'
make check- Sprawdzamy, czy voter jest zarejestrowany automatycznie.
- Testy pilnują pozytywnych i negatywnych decyzji dostępu.
- Tag po bloku:
checkpoint/day3-block9-voter.
Ćwiczenie
- Dodaj drugi atrybut w voterze, np.
ENROLLMENT_MANAGE. - Ustal, czy dostęp ma mieć tylko trener, czy także właściciel zapisu.
- Dopisz test
403dla użytkownika, który nie spełnia reguły. - Uruchom
make checkprzed uznaniem ćwiczenia za skończone.
Done definition: decyzja dostępu jest w voterze, kontroler pozostaje cienki, a negatywny przypadek jest pokryty testem.
Blok 10
LDAP Component i logowanie przez OpenLDAP
Formularz Symfony zostaje, ale hasło i role użytkownika pochodzą z lokalnego OpenLDAP: bind sprawdza credentials, a role wynikają z członkostwa w grupach LDAP.
Model dla ćwiczenia
- LDAP jest katalogiem tożsamości: użytkownik, hasło i grupy żyją poza aplikacją.
form_login_ldapużywa zwykłego formularza, ale binduje się do LDAP.- Provider
ldap_usersładuje użytkownika z katalogu, arole_fetchermapuje grupy LDAP na role Symfony. - Nie ma lokalnego
users_in_memory:ROLE_USERiROLE_TRAINERwynikają z wpisów wdocker/openldap/workshop.ldif.
OpenLDAP w Compose
openldap:
image: bitnamilegacy/openldap:latest
environment:
LDAP_ROOT: dc=symfony,dc=training
LDAP_ADMIN_DN: cn=admin,dc=symfony,dc=training
LDAP_CUSTOM_LDIF_DIR: /ldifs
volumes:
- ./docker/openldap:/ldifs:ro
- openldap_data:/bitnami/openldap
ports:
- "1389:1389"
# docker/openldap/workshop.ldif
# cn=ROLE_USER,ou=groups,... -> participant@example.com
# cn=ROLE_TRAINER,ou=groups,... -> trainer@example.comKlient LDAP i firewall Symfony
services:
Symfony\Component\Ldap\Ldap:
arguments: ["@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter"]
tags: ["ldap"]
security:
providers:
ldap_users:
ldap:
service: Symfony\Component\Ldap\Ldap
base_dn: "%env(resolve:LDAP_USERS_DN)%"
search_dn: "%env(resolve:LDAP_ADMIN_DN)%"
search_password: "%env(resolve:LDAP_ADMIN_PASSWORD)%"
uid_key: uid
role_fetcher: App\Security\LdapGroupRoleFetcher
firewalls:
main:
provider: ldap_users
form_login_ldap:
service: Symfony\Component\Ldap\Ldap
dn_string: "%env(resolve:LDAP_USER_DN_PATTERN)%"Live coding checkpoint
make reset-db
make
make ldap-check
make checkapp:ldap:checkrobi bind admina, listę użytkowników, wypisuje role z grup LDAP i robi bind użytkownika.- Test formularza logowania potwierdza poprawne oraz błędne hasło LDAP.
- Testy trenera i uczestnika logują się formularzem, więc sprawdzają role z LDAP, a nie
loginUser()z rolą w teście. - Uwaga Bitnami: DN użytkownika jest pod
cn=..., auidjest atrybutem wpisu.
Ćwiczenie
- Dodaj trzeciego użytkownika w
docker/openldap/workshop.ldif. - Dodaj go do grupy
ROLE_USERalboROLE_TRAINERprzez atrybutmember. - Wyczyść wolumeny środowiska ćwiczeniowego przez
make reset-db, uruchommakei sprawdźmake ldap-check. - Dopisz test albo smoke, który potwierdza dostęp wynikający z roli pobranej z LDAP.
- Tag po bloku:
checkpoint/day3-block10-ldap.
Done definition: logowanie używa LDAP bind, role aplikacyjne pochodzą z grup LDAP, a make check przechodzi.
Blok 11
Messenger: wiadomości sync i async
Po zapisie na szkolenie wysyłamy dwie wiadomości: jedną natychmiastową i jedną odkładaną do kolejki Doctrine.
Model mentalny
Messageopisuje intencję: co ma się wydarzyć.Handlerwykonuje pracę dla konkretnej wiadomości.MessageBusInterface::dispatch()oddaje decyzję Messengerowi.Transportdecyduje, czy handler wykona się od razu, czy po stronie workera.
Sync: natychmiastowy efekt
final readonly class EnrollmentAuditRequested
{
public function __construct(
public int $enrollmentId,
public string $participantEmail,
public string $courseTitle,
) {
}
}
#[AsMessageHandler]
final readonly class EnrollmentAuditRequestedHandler
{
public function __invoke(EnrollmentAuditRequested $message): void
{
// sync: handler wykonuje się podczas dispatch()
}
}Async: kolejka Doctrine
# .env
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=async&auto_setup=false
framework:
messenger:
transports:
sync: "sync://"
async:
dsn: "%env(MESSENGER_TRANSPORT_DSN)%"
failed: "doctrine://default?queue_name=failed&auto_setup=false"
routing:
App\Message\EnrollmentAuditRequested: sync
App\Message\EnrollmentConfirmationRequested: asyncLive coding checkpoint
make db-init
make messenger-demo
make messenger-consume
make console ARGS='debug:messenger'
make check- Sync zapisuje log od razu w trakcie requestu albo komendy.
- Async trafia do tabeli
messenger_messages. - Worker pobiera wiadomość z transportu
asynci dopiero wtedy uruchamia handler. - Test używa
in-memory://, żeby nie zależeć od realnej kolejki w PHPUnit.
Ćwiczenie
- Dodaj trzecią wiadomość, np. przypomnienie o recertyfikacji.
- Zdecyduj, czy ma być sync czy async i dopisz routing w
messenger.yaml. - Dopisz test: natychmiastowy log dla sync albo wiadomość w transporcie
in-memory://dla async. - Tag po bloku:
checkpoint/day3-block11-messenger.
Done definition: wiadomość ma handler, routing, test i przechodzi make check.
Blok 12
Behat, SymfonyExtension i kryteria akceptacyjne
Nie zastępujemy PHPUnit. Dodajemy cienką warstwę acceptance criteria, która opisuje zachowanie aplikacji językiem zrozumiałym poza kodem.
Co jest acceptance criteria?
- Scenariusz mówi, co użytkownik ma osiągnąć, a nie jak klasa jest zaimplementowana.
Given / When / Thenporządkuje stan początkowy, akcję i oczekiwany wynik.- Jeden scenariusz acceptance może przejść przez kilka warstw aplikacji.
- Na tym szkoleniu wybieramy 2 scenariusze bez Selenium i bez pełnego E2E UI.
Konfiguracja SymfonyExtension
# behat.yml.dist
default:
suites:
acceptance:
paths: ["%paths.base%/features"]
contexts:
- App\Tests\Behat\FeatureContext
extensions:
FriendsOfBehat\SymfonyExtension:
bootstrap: tests/bootstrap.php
kernel:
environment: test
debug: trueFeature jako kontrakt
Feature: Kryteria akceptacyjne zapisu na szkolenie
Scenario: Uczestnik zapisuje sie na kurs i widzi podsumowanie
Given uczestnik "participant@example.com" jest zalogowany
When zapisuje sie na kurs "symfony-foundations" jako "Behat Candidate"
Then widzi podsumowanie zapisu dla "Behat Candidate"
And podsumowanie zawiera certyfikat
Scenario: Anonimowy uzytkownik nie moze otworzyc formularza zapisu
When anonimowy uzytkownik otwiera formularz zapisu "symfony-foundations"
Then zostaje przekierowany do logowaniaContext jako adapter do aplikacji
final class FeatureContext implements Context
{
private KernelBrowser $client;
public function __construct(KernelInterface $kernel)
{
$this->client = new KernelBrowser($kernel);
}
#[When('anonimowy uzytkownik otwiera formularz zapisu :courseSlug')]
public function openEnrollmentForm(string $courseSlug): void
{
$this->client->request('GET', sprintf('/catalog/%s/enroll', $courseSlug));
}
}Live coding checkpoint
make behat
make check
vendor/bin/behat --definitions lmake behatprzygotowuje testową bazę i uruchamia scenariusze.make checkod tego bloku obejmuje również Behat.- Nie testujemy wszystkiego Behatem: PHPUnit nadal pilnuje szczegółów i regresji.
- Tag po bloku:
checkpoint/day3-block12-behat.
Ćwiczenie
- Dopisz scenariusz dla błędnego formularza zapisu albo niedostępnego podsumowania.
- Najpierw zapisz go językiem biznesowym, dopiero potem dopisz kroki w contextcie.
- Nie dubluj testów jednostkowych: Behat ma opisać kluczowy przepływ.
- Done definition: scenariusz przechodzi w
make behati całymake checkzostaje zielony.
Blok 13
Notifier, Mailer i MailCatcher
Domykamy dzień wysyłką maila po zapisie: Notifier buduje notyfikację, Mailer wysyła wiadomość, a MailCatcher pozwala ją zobaczyć lokalnie.
Gdzie Notifier pasuje w Symfony
NotifierInterfaceopisuje intencję: powiadom odbiorcę wybranym kanałem.- Kanał
emailużywa Symfony Mailer pod spodem. - Recipient trzyma adres odbiorcy, a Notification trzyma temat, treść i ważność.
- Messenger może kolejkować domenową wiadomość, a worker może wysłać maila przez Notifier.
MailCatcher i MAILER_DSN
# compose.yaml
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "1080:1080"
- "1025:1025"
# .env
MAILER_DSN=smtp://mailcatcher:1025
# config/packages/mailer.yaml
framework:
mailer:
dsn: "%env(MAILER_DSN)%"
envelope:
sender: notifications@symfony-workshop.localWłasna notyfikacja email
final class EnrollmentConfirmationNotification
extends Notification
implements EmailNotificationInterface
{
public function __construct(string $courseTitle)
{
parent::__construct('Potwierdzenie zapisu: '.$courseTitle, ['email']);
$this->content('Dziekujemy za zapis...');
}
public function asEmailMessage(
EmailRecipientInterface $recipient,
?string $transport = null,
): EmailMessage {
return new EmailMessage((new Email())
->to($recipient->getEmail())
->subject($this->getSubject())
->text($this->getContent()));
}
}Podpięcie do async handlera
#[AsMessageHandler]
final readonly class EnrollmentConfirmationRequestedHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
private EnrollmentConfirmationNotifier $confirmationNotifier,
) {
}
public function __invoke(EnrollmentConfirmationRequested $message): void
{
$this->confirmationNotifier->send(
$message->participantEmail,
$message->courseTitle,
);
// potem zapisujemy log: handledBy=async, notificationChannel=email
}
}Live coding checkpoint
make up
make notifier-demo
make mailcatcher-url
curl http://localhost:1080/messages
make checkmake notifier-demowysyła maila przezNotifierInterface.- MailCatcher pokazuje nadawcę, odbiorcę i temat wiadomości.
- Testy używają
SpyNotifier, więc nie wymagają SMTP. - Tag po bloku:
checkpoint/day3-block13-notifier.
Ćwiczenie
- Zmień treść maila tak, aby zawierała nazwę kursu i link do podsumowania zapisu.
- Dopisz test, który sprawdza temat, odbiorcę i kanał
email. - Sprawdź wiadomość w MailCatcher po
make notifier-demo. - Done definition: notyfikacja jest wysyłana lokalnie, test nie potrzebuje SMTP, a
make checkprzechodzi.
Programowanie aplikacji internetowych w oparciu o framework Symfony
By Leszek Prabucki
Programowanie aplikacji internetowych w oparciu o framework Symfony
- 3