Rozdział 7: Komentarze
Artykuły można już czytać — czas dać czytelnikom głos. Zbudujemy komentarze: wyświetlanie z wątkami
(odpowiedzi), formularz dla zalogowanych, zapis oraz podstawową moderację (nowe komentarze czekają na
zatwierdzenie). Encję BlogComment mamy z Rozdziału 3 — teraz ją ożywiamy.
Rozszerzenie. To pierwszy rozdział, który dokłada funkcję ponad dotychczasowy blog: obok wyświetlania (wątki) dodajemy pełny obieg dodaj → moderuj → publikuj. Wykorzystamy logowanie z Rozdziału 2.
Stan wejściowy: strona artykułu z Rozdziału 5. Komentarze wiszą na encji przez relacje z Rozdziału 3.
Wyświetlanie komentarzy z wątkami
Komentarze są zagnieżdżone (odpowiedź na odpowiedź) dzięki self-referencing parent/replies z
Rozdziału 3. Renderujemy je rekurencyjnym makrem Twiga. Pokazujemy tylko opublikowane (published);
odpowiedzi filtrujemy tak samo:
{# templates/blog/show.html.twig — sekcja komentarzy #}
<h3 class="h5 fw-bold">Komentarze ({{ rootComments|length }})</h3>
<hr>
{% if rootComments is empty %}
<p class="text-muted">Bądź pierwszy — zostaw komentarz!</p>
{% else %}
<div class="d-flex flex-column gap-3">
{% for comment in rootComments %}
{{ _self.render_comment(comment) }}
{% endfor %}
</div>
{% endif %}
{# Rekurencyjne makro — komentarz + jego odpowiedzi #}
{% macro render_comment(comment, depth = 0) %}
<div class="d-flex gap-3 {% if depth > 0 %}ms-4 ps-3 border-start border-2{% endif %}">
<div class="rounded-circle bg-primary bg-opacity-10 text-primary d-flex align-items-center
justify-content-center fw-bold flex-shrink-0" style="width:40px;height:40px;">
{{ comment.user.name|slice(0,1)|upper }}
</div>
<div class="flex-grow-1">
<div class="card border-0 shadow-sm"><div class="card-body p-3">
<div class="d-flex gap-2 mb-2">
<span class="fw-semibold small">{{ comment.user.name }}</span>
<span class="text-muted small">{{ comment.createdAt|date('d.m.Y, H:i') }}</span>
</div>
<p class="mb-0 small">{{ comment.content }}</p>
</div></div>
{% for reply in comment.replies if reply.status == 'published' %}
<div class="mt-3">{{ _self.render_comment(reply, depth + 1) }}</div>
{% endfor %}
</div>
</div>
{% endmacro %}
_self.render_comment(...)wywołuje makro rekurencyjnie — każda odpowiedź jest wcinana (depth).reply.status == 'published'— moderacja działa też na odpowiedziach.- Awatar to pierwsza litera imienia użytkownika (
comment.user.name).
Formularz komentarza
Tworzymy typ formularza BlogCommentType — jedno pole content mapowane na encję:
// src/Form/BlogCommentType.php
namespace App\Form;
use App\Entity\BlogComment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BlogCommentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('content', TextareaType::class, [
'label' => 'Twój komentarz',
'attr' => ['rows' => 3, 'placeholder' => 'Napisz komentarz…'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BlogComment::class,
// Stały identyfikator tokenu CSRF — dzięki temu formularz odpowiedzi
// (zwykły HTML) wygeneruje pasujący token: csrf_token('blog_comment').
'csrf_token_id' => 'blog_comment',
]);
}
}
Dodajemy walidację pola content w encji (pusty komentarz nie ma sensu):
// src/Entity/BlogComment.php
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank(message: 'Komentarz nie może być pusty.')]
private ?string $content = null;
W szablonie formularz pokazujemy tylko zalogowanym (logowanie z Rozdziału 2); gościa zapraszamy do
logowania. Formularz stylujemy motywem Bootstrap 5 — ale tylko lokalnie ({% form_theme %}), żeby nie
zmieniać wyglądu innych formularzy w projekcie:
<div class="card border-0 shadow-sm mt-4">
<div class="card-body p-4">
{% if app.user %}
<h4 class="h6 fw-bold mb-3">Skomentuj artykuł</h4>
{% form_theme commentForm 'bootstrap_5_layout.html.twig' %}
{{ form_start(commentForm, { action: path('app_blog_comment', { slug: article.slug }) }) }}
{{ form_row(commentForm.content, { label: false }) }}
<button class="btn btn-primary">
<i class="fa-regular fa-paper-plane me-1"></i>Dodaj komentarz
</button>
{{ form_end(commentForm) }}
{% else %}
<p class="text-muted small mb-0">
<a href="{{ path('app_login') }}">Zaloguj się</a>, aby dodać komentarz.
</p>
{% endif %}
</div>
</div>
{% form_theme commentForm 'bootstrap_5_layout.html.twig' %}dokładaform-controldo textarea i poprawny układ — bez tego label nachodziłby na pole.{ label: false }— wystarcza placeholder, nie potrzebujemy etykiety nad polem.
Zapis komentarza
Osobna akcja comment() (tylko POST, tylko dla zalogowanych) przyjmuje formularz, ustawia autora
z sesji i zapisuje komentarz ze statusem pending (czeka na moderację):
// src/Controller/BlogController.php
use App\Entity\BlogComment;
use App\Form\BlogCommentType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/{slug}/comment', name: 'comment', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function comment(string $slug, Request $request, EntityManagerInterface $em): Response
{
$article = $this->articles->findOneBy(['slug' => $slug]);
if (null === $article || BlogArticle::STATUS_PUBLISHED !== $article->getStatus()) {
throw new NotFoundHttpException();
}
$comment = new BlogComment();
$form = $this->createForm(BlogCommentType::class, $comment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$comment->setArticle($article)
->setStatus(BlogComment::STATUS_PENDING);
$user = $this->getUser();
if ($user instanceof \App\Entity\User) {
$comment->setUser($user);
}
// opcjonalna odpowiedź: ukryte pole parentId, walidowane że należy do tego artykułu
$parentId = $request->request->getInt('parentId');
if (0 !== $parentId
&& ($parent = $em->find(BlogComment::class, $parentId)) instanceof BlogComment
&& $parent->getArticle() === $article) {
$comment->setParent($parent);
}
$em->persist($comment);
$em->flush();
$this->addFlash('success', 'Dziękujemy! Twój komentarz czeka na zatwierdzenie.');
}
return $this->redirectToRoute('app_blog_show', ['slug' => $slug]);
}
Najważniejsze:
#[IsGranted('ROLE_USER')]— niezalogowany zostanie przekierowany do/login(firewall z Rozdziału 2).$this->getUser()zwraca zalogowaną encjęUser— to ona jest autorem (comment.user,nullable:false).- Odpowiedź (wątek): jeśli formularz niesie
parentId, dowiązujemy rodzica — po sprawdzeniu, że należy do tego samego artykułu (bez tego można by „podpiąć” komentarz pod cudzy artykuł). - Po zapisie redirect na stronę artykułu (wzorzec POST→Redirect→GET) i komunikat flash.
Odpowiedzi z UI
Backend obsługuje już odpowiedzi (pole parentId wyżej) — dodajmy je do interfejsu. Pod każdym komentarzem,
dla zalogowanych, pokazujemy przycisk „Odpowiedz”, który rozwija (Bootstrap collapse) mały formularz.
To zwykły formularz HTML kierujący do tej samej akcji comment, z ukrytym parentId i pasującym
tokenem CSRF. Dodajemy to do makra render_comment — zaraz po karcie komentarza:
{% if app.user %}
<div class="mt-1">
<button class="btn btn-link btn-sm text-decoration-none p-0" type="button"
data-bs-toggle="collapse" data-bs-target="#reply-{{ comment.id }}">
<i class="fa-solid fa-reply me-1"></i>Odpowiedz
</button>
<div class="collapse mt-2" id="reply-{{ comment.id }}">
<form method="post" action="{{ path('app_blog_comment', { slug: comment.article.slug }) }}">
<input type="hidden" name="blog_comment[_token]" value="{{ csrf_token('blog_comment') }}">
<input type="hidden" name="parentId" value="{{ comment.id }}">
<textarea name="blog_comment[content]" class="form-control form-control-sm mb-2"
rows="2" placeholder="Twoja odpowiedź…" required></textarea>
<button class="btn btn-primary btn-sm">Odpowiedz</button>
</form>
</div>
</div>
{% endif %}
data-bs-toggle="collapse"— chowanie/pokazywanie formularza bez własnego JS (Bootstrap dołączyliśmy w Rozdziale 4).blog_comment[content]iblog_comment[_token]— nazwy pól muszą pasować do formularza Symfony, żebyhandleRequest()je związał. Token generujemy funkcjącsrf_token('blog_comment')— dlatego wcześniej przypięliśmycsrf_token_id: 'blog_comment'naBlogCommentType.comment.article.slug— encja komentarza zna swój artykuł, więc nie musimy niczego przekazywać do makra.- Odpowiedź, jak każdy komentarz, trafia do moderacji (
pending).
Moderacja
Nowe komentarze mają status pending i nie są widoczne publicznie, dopóki admin ich nie zatwierdzi.
Statusy (published | pending | spam) zdefiniowaliśmy na encji w Rozdziale 3. Potrzebujemy dwóch rzeczy.
1. Akcja zatwierdzenia (tylko ROLE_ADMIN) — zmienia status na published:
#[Route('/comment/{id}/approve', name: 'comment_approve', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function approveComment(BlogComment $comment, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('approve-comment-' . $comment->getId(), (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Nieprawidłowy token CSRF.');
}
$comment->setStatus(BlogComment::STATUS_PUBLISHED);
$em->flush();
$this->addFlash('success', 'Komentarz zatwierdzony.');
return $this->redirectToRoute('app_blog_show', ['slug' => $comment->getArticle()?->getSlug()]);
}
BlogComment $comment wstrzeliwuje EntityValueResolver po {id}. Akcja jest chroniona ROLE_ADMIN
oraz tokenem CSRF — to akcja zmieniająca stan (POST), więc zabezpieczamy ją tokenem, tak jak destrukcyjne
akcje w reszcie projektu.
2. Widok moderacji — akcję show() z Rozdziału 5 rozszerzamy: publiczne komentarze widzą wszyscy,
a oczekujące pokazujemy tylko adminowi z przyciskiem „Zatwierdź”:
public function show(string $slug, EntityManagerInterface $em): Response
{
// … rozwiązanie artykułu, incrementViews, pushRecentlyViewed (Rozdział 5) …
$rootComments = array_filter(
$article->getComments()->toArray(),
static fn(BlogComment $c) => null === $c->getParent()
&& BlogComment::STATUS_PUBLISHED === $c->getStatus(),
);
$pending = $this->isGranted('ROLE_ADMIN')
? array_filter(
$article->getComments()->toArray(),
static fn(BlogComment $c) => BlogComment::STATUS_PENDING === $c->getStatus(),
)
: [];
return $this->render('blog/show.html.twig', [
'article' => $article,
'rootComments' => array_values($rootComments),
'pending' => array_values($pending),
'commentForm' => $this->createForm(BlogCommentType::class)->createView(),
]);
}
W szablonie — sekcja moderacji widoczna tylko dla admina:
{% if pending is not empty %}
<div class="alert alert-warning mt-4">
<h4 class="h6">Do moderacji ({{ pending|length }})</h4>
{% for comment in pending %}
<div class="d-flex justify-content-between align-items-center border-top py-2 gap-2">
<span class="small"><strong>{{ comment.user.name }}:</strong> {{ comment.content }}</span>
<form method="post" action="{{ path('app_blog_comment_approve', { id: comment.id }) }}" class="m-0">
<input type="hidden" name="_token" value="{{ csrf_token('approve-comment-' ~ comment.id) }}">
<button class="btn btn-sm btn-success">Zatwierdź</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
Pełny obieg działa: użytkownik dodaje komentarz (→ pending), admin widzi go na stronie artykułu i klika
„Zatwierdź” (→ published), po czym komentarz pojawia się publicznie.
Status
spammożesz wykorzystać analogicznie (druga akcja / przycisk „Oznacz jako spam”). Rozbudowany panel moderacji zbudujemy przy okazji panelu admina w Rozdziale 8.
Podsumowanie
Blog ma pełny system komentarzy:
- ✅ wyświetlanie z wątkami — rekurencyjny makro Twiga (self-referencing
parent/repliesz Rozdziału 3), tylkopublished, - ✅ formularz
BlogCommentType(polecontent+ walidacjaNotBlank) ostylowany motywem Bootstrap 5 (lokalnie), widoczny tylko dla zalogowanych, - ✅ zapis akcją
POSTz#[IsGranted('ROLE_USER')], autor zgetUser(), - ✅ odpowiedzi z UI — przycisk „Odpowiedz” + rozwijany formularz (Bootstrap collapse) z
parentIdi pasującym tokenem CSRF (csrf_token_id), - ✅ moderacja: nowe komentarze
pending, akcjaapprove(ROLE_ADMIN+ token CSRF) i widok moderacji dla admina, - ✅ bezpieczeństwo: logowanie (rozdz. 2), role, CSRF na formularzach i akcji zatwierdzania, walidacja rodzica odpowiedzi,
- ✅ działający stan: zalogowany dodaje komentarz/odpowiedź → admin zatwierdza → treść widoczna publicznie.
W Rozdziale 8 zbudujemy panel administracyjny — ręczny CRUD artykułów, kategorii i tagów (formularze
Symfony, auto-slug, ochrona ROLE_ADMIN) — domykając Część I (działający blog).