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.git
cd symfony-traning-platform-workshop
make
  • make buduje 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:router pozwala szybko sprawdzić, co naprawdę wystawiamy.
  • 404 jest 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

  • extends i 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 slug koń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 Course jako pierwszy trwały element modelu.
  • W kolejnych blokach dojdą Enrollment, ExamAttempt i Certificate.
  • 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.
  • CourseRepository odpowiada za pobranie danych z bazy.
  • CourseCatalog zostaje 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:diff generuje historię tej zmiany.
  • doctrine:migrations:migrate odtwarza 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

  • Course jest pierwszą encją domenową.
  • CourseRepository przejmuje odczyt danych.
  • CourseCatalog pozostaje warstwą aplikacyjną dla flow katalogu.
  • AppFixtures i make db-init dają 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-init i potwierdź wynik przez make 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() i isValid() 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 CourseEnrollmentInput prowadzi do Enrollment, ExamAttempt i Certificate.

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}/enroll obsługuje ekran i submit formularza.
  • CourseEnrollmentInput i CourseEnrollmentType definiują wejście użytkownika.
  • CourseEnrollmentWorkflow zapisuje Enrollment, ExamAttempt i Certificate.
  • /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 test i lint: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 OneToMany w tym bloku.
  • Adresy są częścią DTO wejściowego CourseEnrollmentInput.
  • Statyczny AddressCatalog daje 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_type mówi, jaki formularz ma dostać każdy element kolekcji.
  • allow_add i prototype pozwalają tworzyć nowe wpisy w przeglądarce.
  • delete_empty usuwa 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 check jest 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_control wiąże ścieżki HTTP z minimalnym wymaganiem dostępu.

Logowanie i chronione flow

  • form_login daje prosty, czytelny webowy punkt wejścia do aplikacji.
  • Anonim trafiający na chronioną trasę jest przekierowywany do /login.
  • logout domyka 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_USER i ROLE_TRAINER wystarczają 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 check scala 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-fixer normalizuje styl mechanicznie, więc nie warto o nim dyskutować na review.
  • phpstan pokazuje problemy kontraktu i typów, zanim zrobi to runtime.
  • phpunit pilnuje przepływów biznesowych oraz regresji.
  • Najpierw czytamy pierwszy czerwony sygnał i naprawiamy przyczynę, nie objaw.

Mapowanie na nasz projekt

  • security.yaml definiuje provider, firewall i access_control.
  • /login i /logout obsługują kontrakt sesji użytkownika.
  • Enrollment flow wymaga ROLE_USER, a dashboard ROLE_TRAINER.
  • make check spina php-cs-fixer, phpstan i phpunit.

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_USER czy ROLE_TRAINER.
  • Dopisz test na redirect do logowania albo na 403.
  • Uruchom make check i 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: Course i Certificate.
  • 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 Get i GetCollection jawnie 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/docs służy do szybkiej inspekcji przez przeglądarkę.
  • /api/docs.jsonopenapi daje 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

  • Course i Certificate są wystawione jako zasoby API Platform.
  • /api/docs i /api/docs.jsonopenapi opisują 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 /api i 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_USER mó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.yaml voter jest autokonfigurowany tagiem security.voter.
  • Kontroler wywołuje denyAccessUnlessGranted(EnrollmentVoter::VIEW, $enrollment).
  • Decyzję potwierdzają testy: własny zapis 200, cudzy zapis 403, trener 200.

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 403 dla użytkownika, który nie spełnia reguły.
  • Uruchom make check przed 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_ldap używa zwykłego formularza, ale binduje się do LDAP.
  • Provider ldap_users ładuje użytkownika z katalogu, a role_fetcher mapuje grupy LDAP na role Symfony.
  • Nie ma lokalnego users_in_memory: ROLE_USER i ROLE_TRAINER wynikają z wpisów w docker/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.com

Klient 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 check
  • app:ldap:check robi 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=..., a uid jest atrybutem wpisu.

Ćwiczenie

  • Dodaj trzeciego użytkownika w docker/openldap/workshop.ldif.
  • Dodaj go do grupy ROLE_USER albo ROLE_TRAINER przez atrybut member.
  • Wyczyść wolumeny środowiska ćwiczeniowego przez make reset-db, uruchom make i 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

  • Message opisuje intencję: co ma się wydarzyć.
  • Handler wykonuje pracę dla konkretnej wiadomości.
  • MessageBusInterface::dispatch() oddaje decyzję Messengerowi.
  • Transport decyduje, 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: async

Live 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 async i 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 / Then porzą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: true

Feature 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 logowania

Context 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 l
  • make behat przygotowuje testową bazę i uruchamia scenariusze.
  • make check od 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 behat i cały make check zostaje 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

  • NotifierInterface opisuje intencję: powiadom odbiorcę wybranym kanałem.
  • Kanał email uż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.local

Wł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 check
  • make notifier-demo wysyła maila przez NotifierInterface.
  • 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 check przechodzi.