Symfony 7.4 • Docker Compose • PostgreSQL 18.3 • Twig • Doctrine • API Platform • PHPUnit • PHPStan • PHP CS Fixer
Leszek Prabucki
PHP/Angular Tech Leader & Solutions Architect
git clone https://github.com/l3l0/symfony-traning-platform-workshop.gitcd symfony-traning-platform-workshop make
make buduje obraz, stawia kontenery i wykonuje podstawowy bootstrap projektu.http://localhost:8080.make db-init.Jedna narastająca aplikacja: od lokalnego runtime do pierwszego web flow katalogu szkoleń i certyfikacji.
<?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);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
HTTP request -> routing -> controller -> Twig -> HTTP response
make
make about
make health
make test/.Done definition: aplikacja działa lokalnie, / odpowiada 200 i test przechodzi.
Rozszerzamy katalog do przepływu lista -> szczegóły i porządkujemy odpowiedzialności między routingiem, kontrolerem, usługą i widokiem.
lista -> szczegóły.200/404.{slug}, stają się wejściem do akcji.#[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.Response.<?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(),
]);
}
}
{% 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 %}extends i layout bazowy porządkują wspólny szkielet aplikacji.path() służy do bezpiecznego linkowania między ekranami./ pokazuje listę ścieżek szkoleniowych./catalog/{slug} pokazuje szczegóły konkretnego kursu.slug kończy się 404, co też testujemy.GET /
GET /catalog/symfony-foundations
GET /catalog/not-existing
bin/console debug:router
make test200.200.404.slug.404.Done definition: lista i szczegóły działają, routing jest czytelny, a testy funkcjonalne przechodzą.
Przechodzimy z danych in-memory do bazy i porządkujemy model aplikacji tak, żeby kolejne kroki MVP miały trwały fundament.
Course, repozytorium, migrację i fixtures.Course jako pierwszy trwały element modelu.Enrollment, ExamAttempt i Certificate.<?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
CourseRepository odpowiada za pobranie danych z bazy.CourseCatalog zostaje cienką usługą aplikacyjną nad repozytorium.doctrine:migrations:diff generuje historię tej zmiany.doctrine:migrations:migrate odtwarza schemat w przewidywalny sposób na każdym środowisku.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.make db-init
make test
make console ARGS='doctrine:schema:validate'
GET /
GET /catalog/symfony-foundationsCourse, wygeneruj migrację i pokaż je w widoku.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.
Budujemy pierwszy pełny proces biznesowy: od formularza uczestnika do wyniku egzaminu i certyfikatu widocznego na stronie podsumowania.
createForm(), handleRequest(), isSubmitted(), isValid().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.CourseEnrollmentInput prowadzi do Enrollment, ExamAttempt i Certificate.Assert* deklarują reguły blisko obiektu wejściowego.422./enrollments/{token}, więc URL nie ujawnia wewnętrznego ID.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.GET /catalog/symfony-foundations/enroll
POST /catalog/symfony-foundations/enroll
GET /enrollments/{token}
make testAssert*.Done definition: formularz zapisuje dane, błędy są czytelne, a summary pokazuje kompletny wynik procesu.
Najpierw wyciągamy powtarzalny markup do małego komponentu Twig, a potem dokładamy krótki przykład async search przez Symfony UX Autocomplete.
{# 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><twig:CourseCard :course="course" />.make test i lint:twig templates.#[AsEntityAutocompleteField]./autocomplete/{alias} i zwraca JSON dla Tom Select.Course, bez zmiany workflow zapisu.#[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;
}
}Rozszerzamy formularz zapisu o kolekcję adresów szkoleniowych: województwo, miasto zależne od województwa i adres.
OneToMany w tym bloku.CourseEnrollmentInput.AddressCatalog daje listę województw i miast.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 = '';
}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(),
])__name__.<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>make test
make console ARGS='lint:twig templates'
make console ARGS='debug:asset-map --no-debug'
[trainingAddresses][1].Dokładamy logowanie, autoryzację oraz jeden powtarzalny quality gate, który da się odpalić lokalnie i później przenieść do CI.
make check jest codziennym kontraktem pracy, a nie tylko dodatkiem na koniec.access_control wiąże ścieżki HTTP z minimalnym wymaganiem dostępu.form_login daje prosty, czytelny webowy punkt wejścia do aplikacji./login.logout domyka kontrakt sesji bez dodatkowej logiki kontrolera.ROLE_USER i ROLE_TRAINER wystarczają do naszego MVP.make check scala styl, analizę statyczną i testy w jeden rytuał pracy.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.security.yaml definiuje provider, firewall i access_control./login i /logout obsługują kontrakt sesji użytkownika.ROLE_USER, a dashboard ROLE_TRAINER.make check spina php-cs-fixer, phpstan i phpunit.GET /login
GET /catalog/symfony-foundations/enroll
GET /trainer/dashboard
make checkROLE_USER czy ROLE_TRAINER.403.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.
Po domknięciu webowego MVP wystawiamy wąski, czytelny read API i pokazujemy, jak kod generuje dokumentację kontraktu.
Course i Certificate.#[ApiResource] oznacza klasę jako publiczny zasób API.Get i GetCollection jawnie definiują, co wystawiamy./api/docs służy do szybkiej inspekcji przez przeglądarkę./api/docs.jsonopenapi daje kontrakt maszynowy dla integracji i testów.Course i Certificate są wystawione jako zasoby API Platform./api/docs i /api/docs.jsonopenapi opisują ten sam kontrakt.GET /api/courses
GET /api/certificates
GET /api/docs
GET /api/docs.jsonopenapi
make check/api i w /api/docs.jsonopenapi.Done definition: endpoint zwraca nowy kontrakt, dokumentacja jest spójna, a make check dalej przechodzi.
Po rolach i API dokładamy precyzyjną autoryzację: nie tylko kto jest zalogowany, ale czy może zobaczyć konkretny zapis.
ROLE_USER mówi, że użytkownik przeszedł przez bramkę aplikacji.Enrollment.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));
}
}services.yaml voter jest autokonfigurowany tagiem security.voter.denyAccessUnlessGranted(EnrollmentVoter::VIEW, $enrollment).200, cudzy zapis 403, trener 200.make test
make console ARGS='debug:container --tag=security.voter'
make checkcheckpoint/day3-block9-voter.ENROLLMENT_MANAGE.403 dla użytkownika, który nie spełnia reguły.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.
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.
form_login_ldap używa zwykłego formularza, ale binduje się do LDAP.ldap_users ładuje użytkownika z katalogu, a role_fetcher mapuje grupy LDAP na role Symfony.users_in_memory: ROLE_USER i ROLE_TRAINER wynikają z wpisów w docker/openldap/workshop.ldif.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.comservices:
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)%"make reset-db
make
make ldap-check
make checkapp:ldap:check robi bind admina, listę użytkowników, wypisuje role z grup LDAP i robi bind użytkownika.loginUser() z rolą w teście.cn=..., a uid jest atrybutem wpisu.docker/openldap/workshop.ldif.ROLE_USER albo ROLE_TRAINER przez atrybut member.make reset-db, uruchom make i sprawdź make ldap-check.checkpoint/day3-block10-ldap.Done definition: logowanie używa LDAP bind, role aplikacyjne pochodzą z grup LDAP, a make check przechodzi.
Po zapisie na szkolenie wysyłamy dwie wiadomości: jedną natychmiastową i jedną odkładaną do kolejki Doctrine.
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.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()
}
}# .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: asyncmake db-init
make messenger-demo
make messenger-consume
make console ARGS='debug:messenger'
make checkmessenger_messages.async i dopiero wtedy uruchamia handler.in-memory://, żeby nie zależeć od realnej kolejki w PHPUnit.messenger.yaml.in-memory:// dla async.checkpoint/day3-block11-messenger.Done definition: wiadomość ma handler, routing, test i przechodzi make check.
Nie zastępujemy PHPUnit. Dodajemy cienką warstwę acceptance criteria, która opisuje zachowanie aplikacji językiem zrozumiałym poza kodem.
Given / When / Then porządkuje stan początkowy, akcję i oczekiwany wynik.# 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: 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 logowaniafinal 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));
}
}make behat
make check
vendor/bin/behat --definitions lmake behat przygotowuje testową bazę i uruchamia scenariusze.make check od tego bloku obejmuje również Behat.checkpoint/day3-block12-behat.make behat i cały make check zostaje zielony.Domykamy dzień wysyłką maila po zapisie: Notifier buduje notyfikację, Mailer wysyła wiadomość, a MailCatcher pozwala ją zobaczyć lokalnie.
NotifierInterface opisuje intencję: powiadom odbiorcę wybranym kanałem.email używa Symfony Mailer pod spodem.# 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.localfinal 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()));
}
}#[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
}
}make up
make notifier-demo
make mailcatcher-url
curl http://localhost:1080/messages
make checkmake notifier-demo wysyła maila przez NotifierInterface.SpyNotifier, więc nie wymagają SMTP.checkpoint/day3-block13-notifier.email.make notifier-demo.make check przechodzi.