Dzień 4: Kontroler i widok
Mamy model danych z ofertami w bazie (Dzień 3). Czas pokazać je użytkownikowi. Dziś zbudujemy
JobController z dwiema stronami: listą wszystkich ofert oraz stroną szczegółów pojedynczej
oferty. Poznamy wzorzec MVC, atrybut #[Route], layout Twig i Bootstrap 5.
W Symfony 8 kontrolery pobierają zależności przez wstrzykiwanie w argumentach (nie przez
$this->getDoctrine()), a encję do akcji dostajemy automatycznie dzięki wbudowanemu EntityValueResolver.
Architektura MVC
Najpopularniejszym sposobem organizacji kodu aplikacji webowej jest wzorzec MVC. Dzieli on kod na trzy warstwy:
- Model — logika biznesowa i dostęp do danych. W Symfony to encje (
src/Entity/) i repozytoria (src/Repository/). - View (widok) — to, co widzi użytkownik. W Symfony są to szablony Twig w katalogu
templates/. - Controller (kontroler) — kod, który pobiera dane z Modelu i przekazuje je do Widoku. Wszystkie
żądania trafiają najpierw do front controllera (
public/index.php), który deleguje pracę do akcji (metod kontrolera).
Kontroler
Utwórz src/Controller/JobController.php:
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
final class JobController extends AbstractController
{
// akcje dodamy za chwilę
}
Zmiana vs Symfony 4.2: trasy definiujemy atrybutem
#[Route], a nie adnotacją/** @Route */w DocBlocku. Klasę oznaczamyfinal— to dobra praktyka dla kontrolerów.
Layout (base.html.twig)
Jeśli spojrzysz na makiety z Dnia 2, większość stron wygląda podobnie (nagłówek, stopka, nawigacja). Aby uniknąć duplikacji, używamy wzorca dekoratora: wspólny szablon (layout) „opakowuje” treść poszczególnych stron.
Otwórz templates/base.html.twig i zastąp jego zawartość layoutem opartym na Bootstrap 5:
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Jobeet — najlepsza tablica ofert pracy{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
{% block stylesheets %}{% endblock %}
{% block javascripts %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand fw-bold" href="{{ path('job_list') }}">Jobeet</a>
<a href="#" class="btn btn-outline-light ms-auto">Dodaj ofertę</a>
</div>
</nav>
<div class="container my-4">
{% block body %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
Zmiana vs Symfony 4.2: używamy Bootstrap 5 (bez zależności od jQuery) zamiast Bootstrap 3 oraz Font Awesome 6 zamiast glyphiconów (usuniętych w Bootstrap 4+).
Assety: CDN czy AssetMapper?
Dla prostoty tutoriala ładujemy Bootstrap z CDN. W realnym projekcie lepiej użyć AssetMappera
(domyślnego w pakiecie --webapp), który zarządza zależnościami frontendu bez Node.js:
docker compose exec app php bin/console importmap:require bootstrap
Akcja listy ofert
Każda akcja to metoda kontrolera. Dla listy ofert stworzymy metodę list(). Repozytorium JobRepository
wstrzykujemy jako argument — Symfony automatycznie je poda:
<?php
namespace App\Controller;
use App\Entity\Job;
use App\Repository\JobRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class JobController extends AbstractController
{
#[Route('/', name: 'job_list', methods: ['GET'])]
public function list(JobRepository $jobs): Response
{
return $this->render('job/list.html.twig', [
'jobs' => $jobs->findAll(),
]);
}
}
Metoda list() pobiera z repozytorium wszystkie oferty (findAll()) i przekazuje je do szablonu.
Atrybut #[Route] mówi Symfony, że ścieżka / (strona główna) jest obsługiwana przez tę akcję.
Zmiana vs Symfony 4.2: zamiast
$this->getDoctrine()->getRepository(Job::class)(metodagetDoctrine()została usunięta w Symfony 6) wstrzykujemyJobRepositorybezpośrednio w argumencie akcji. To czytelniejsze i łatwiejsze do testowania.
Na razie pobieramy wszystkie oferty. Filtrowanie tylko aktywnych i publicznych (zgodnie z historią F1) dodamy w kolejnych dniach, gdy poznamy repozytoria i QueryBuilder.
Szablon listy ofert
W akcji przekazaliśmy oferty do job/list.html.twig. Utwórz ten plik w templates/job/:
{% extends 'base.html.twig' %}
{% block title %}Jobeet — oferty pracy{% endblock %}
{% block body %}
<table class="table table-hover text-center align-middle">
<thead>
<tr>
<th class="text-center">Lokalizacja</th>
<th class="text-center">Stanowisko</th>
<th class="text-center">Firma</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>{{ job.location }}</td>
<td>
<a href="{{ path('job_show', { id: job.id }) }}">{{ job.position }}</a>
</td>
<td>{{ job.company }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-muted">Brak ofert.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
Link do stanowiska generujemy funkcją path('job_show', { id: job.id }) — dzięki temu adresy URL są
budowane z nazw tras, a nie wpisywane na sztywno.
Bloki Twig
W Twigu definiujemy bloki ({% block %}). Blok może mieć domyślną treść (jak title w layoucie),
którą szablon potomny może nadpisać lub rozszerzyć.
Tag extends w list.html.twig oznacza, że szablon dziedziczy po base.html.twig i może
przedefiniować jego bloki. Tutaj wypełniamy blok body oraz nadpisujemy title.
Akcja szczegółów oferty
Mamy listę — dodajmy akcję pokazującą pojedynczą ofertę. Dopisz do JobController:
#[Route('/job/{id}', name: 'job_show', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(Job $job): Response
{
return $this->render('job/show.html.twig', [
'job' => $job,
]);
}
Zwróć uwagę na argument Job $job. Skąd Symfony wie, którą ofertę wczytać? Używa parametru {id} z
adresu URL, sam odpytuje bazę i wstrzykuje gotowy obiekt Job. Jeśli oferta o danym id nie istnieje,
automatycznie zwróci błąd 404.
Zmiana vs Symfony 4.2: to automatyczne wczytywanie encji zapewnia dziś wbudowany
EntityValueResolver(Symfony 6.2+). W oryginale wymagało to@ParamConverterzSensioFrameworkExtraBundle— dziś zbędnego. W razie potrzeby konfigurujemy je atrybutem#[MapEntity].
Szablon szczegółów oferty
Utwórz templates/job/show.html.twig:
{% extends 'base.html.twig' %}
{% block title %}{{ job.position }} — {{ job.company }}{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-start mb-3">
<h1 class="h3 mb-0">
<strong>{{ job.company }}</strong>
<small class="text-muted">({{ job.location }})</small>
</h1>
<span class="text-muted">
<i class="fa-regular fa-clock me-1"></i>{{ job.createdAt|date('d.m.Y') }}
</span>
</div>
<p class="lead">
{{ job.position }}
<small class="text-muted"> — {{ job.type }}</small>
</p>
<p>{{ job.description|nl2br }}</p>
<h2 class="h5 mt-4">Jak aplikować?</h2>
<p>{{ job.howToApply }}</p>
<div class="text-end mt-4">
<a class="btn btn-outline-secondary" href="{{ path('job_list') }}">
<i class="fa-solid fa-arrow-left me-1"></i>Powrót do listy
</a>
<a class="btn btn-primary" href="#">
<i class="fa-solid fa-pen me-1"></i>Edytuj
</a>
</div>
{% endblock %}
Odśwież http://localhost:8090 — zobaczysz listę ofert z fixtures. Kliknij stanowisko, aby przejść do strony szczegółów.
Filtry i funkcje Twig
Twig ma bogaty zestaw wbudowanych filtrów i funkcji. W szablonach użyliśmy:
path('job_show', { id: job.id })— funkcja generująca URL z nazwy trasy,job.createdAt|date('d.m.Y')— filtr formatujący datę,job.description|nl2br— filtr zamieniający znaki nowej linii na<br>.
Zmiana vs Symfony 4.2: korzystamy z Twig 3 (usunięto przestarzałe funkcje z Twig 1/2). Uwaga na wielkość liter: właściwość encji to
createdAt, więc w Twigu piszemyjob.createdAt.
Podsumowanie
Mamy pierwsze działające strony Jobeet:
- ✅
JobControllerz akcjamilist()ishow()opartymi na atrybucie#[Route], - ✅ repozytorium wstrzykiwane w argumencie akcji (bez
getDoctrine()), - ✅ automatyczne wczytywanie encji
Jobprzez EntityValueResolver, - ✅ layout
base.html.twig(wzorzec dekoratora) na Bootstrap 5 + Font Awesome, - ✅ szablony listy i szczegółów oferty z dziedziczeniem Twig.
W Dniu 5 zajmiemy się routingiem: nadamy adresom URL czytelną postać, dodamy wymagania parametrów i nauczymy się generować oraz debugować trasy.