4

Dzień 4 z 18

Kontroler i widok

Opublikowany

Budujemy pierwsze strony aplikacji. Poznamy wzorzec MVC, stworzymy JobController z listą ofert i stroną szczegółową, layout na Bootstrap 5 oraz szablony Twig z dziedziczeniem.

Czego się nauczysz

  • Wzorzec MVC — Model, View, Controller w Symfony
  • JobController i akcje przez atrybut #[Route]
  • Wstrzykiwanie repozytorium w argumencie akcji
  • Layout base.html.twig (wzorzec dekoratora) na Bootstrap 5
  • Lista ofert (list) i szablon Twig
  • Szczegóły oferty (show) i automatyczne wczytywanie encji
  • Bloki, filtry i funkcje Twig

Co zmieniło się w Symfony 8 vs 4.2

  • Atrybut #[Route] zamiast adnotacji @Route
  • Wstrzykiwanie repozytorium zamiast $this->getDoctrine() (usunięte w SF6)
  • Wbudowany EntityValueResolver zamiast @ParamConverter (SensioFrameworkExtraBundle)
  • Bootstrap 5 (bez jQuery) i Font Awesome zamiast Bootstrap 3 + glyphicons
  • Twig 3 zamiast Twig 2

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ę oznaczamy final — 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) (metoda getDoctrine() została usunięta w Symfony 6) wstrzykujemy JobRepository bezpoś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 @ParamConverter z SensioFrameworkExtraBundle — 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 piszemy job.createdAt.


Podsumowanie

Mamy pierwsze działające strony Jobeet:

  • ✅ JobController z akcjami list() i show() opartymi na atrybucie #[Route],
  • ✅ repozytorium wstrzykiwane w argumencie akcji (bez getDoctrine()),
  • ✅ automatyczne wczytywanie encji Job przez 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.

Narzędzia / paczki

Twig 3 Bootstrap 5 Font Awesome 6 AssetMapper

Spis treści