7

Rozdział 7 z 21

Komentarze

Opublikowany

Pozwalamy czytelnikom komentować artykuły. Zbudujemy formularz komentarza, zapiszemy komentarz powiązany z artykułem, obsłużymy wątki (odpowiedzi przez self-referencing parent) oraz podstawową moderację.

Czego się nauczysz

  • Encja BlogComment i powiązanie z artykułem
  • Wątki — self-referencing parent (odpowiedzi)
  • Formularz komentarza (Symfony Form)
  • Zapis komentarza i walidacja
  • Podstawowa moderacja komentarzy

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łada form-control do 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] i blog_comment[_token] — nazwy pól muszą pasować do formularza Symfony, żeby handleRequest() je związał. Token generujemy funkcją csrf_token('blog_comment') — dlatego wcześniej przypięliśmy csrf_token_id: 'blog_comment' na BlogCommentType.
  • 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 spam moż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/replies z Rozdziału 3), tylko published,
  • ✅ formularz BlogCommentType (pole content + walidacja NotBlank) ostylowany motywem Bootstrap 5 (lokalnie), widoczny tylko dla zalogowanych,
  • ✅ zapis akcją POST z #[IsGranted('ROLE_USER')], autor z getUser(),
  • ✅ odpowiedzi z UI — przycisk „Odpowiedz” + rozwijany formularz (Bootstrap collapse) z parentId i pasującym tokenem CSRF (csrf_token_id),
  • ✅ moderacja: nowe komentarze pending, akcja approve (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).

Narzędzia / paczki

Symfony Form Doctrine (self-referencing)

Spis treści