8

Rozdział 8 z 21

Panel administracyjny

Opublikowany

Budujemy panel administracyjny — ręczny CRUD (zgodnie z konwencją projektu, bez EasyAdmin): tworzenie i edycja artykułów, kategorii i tagów na formularzach Symfony, automatyczny slug, ustawianie publishedAt, ochrona przez ROLE_ADMIN i CSRF.

Czego się nauczysz

  • Ręczny CRUD artykułów/kategorii/tagów (bez EasyAdmin)
  • Formularze Symfony (BlogArticleType itd.)
  • Automatyczny slug (cocur/slugify)
  • Statusy i auto-publishedAt
  • Ochrona /admin przez ROLE_ADMIN i CSRF

Rozdział 8: Panel administracyjny

Ostatni element podstawowego bloga — panel admina. Zbudujemy ręczny CRUD (bez EasyAdmin) do zarządzania artykułami, kategoriami i tagami: formularze Symfony, automatyczny slug, ustawianie publishedAt i ochrona całości rolą ROLE_ADMIN. Po tym rozdziale Część I jest kompletna — masz w pełni działający, zarządzalny blog.

Stan wejściowy: encje (Rozdz. 3), logowanie i role (Rozdz. 2). Panel to osobna warstwa kontrolerów pod /admin/blog.


Osobna warstwa i ochrona

Panel to osobny kontroler w src/Controller/Admin/, z prefiksem /admin/blog i ochroną na poziomie klasy — cały panel wymaga ROLE_ADMIN:

// src/Controller/Admin/BlogAdminController.php
namespace App\Controller\Admin;

use App\Entity\BlogArticle;
use App\Form\BlogArticleType;
use App\Repository\BlogArticleRepository;
use Cocur\Slugify\Slugify;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/blog', name: 'admin_blog_')]
#[IsGranted('ROLE_ADMIN')]
class BlogAdminController extends AbstractController
{
    #[Route('', name: 'index')]
    public function index(BlogArticleRepository $repo): Response
    {
        $articles = $repo->createQueryBuilder('a')
            ->leftJoin('a.category', 'c')->addSelect('c')   // eager JOIN — brak N+1 na liście
            ->orderBy('a.createdAt', 'DESC')
            ->getQuery()
            ->getResult();

        return $this->render('admin/blog/index.html.twig', ['articles' => $articles]);
    }
}
  • #[IsGranted('ROLE_ADMIN')] na klasie — jedna adnotacja chroni wszystkie akcje panelu. Dodatkowo broni ich reguła access_control: ^/admin z Rozdziału 2 (pas i szelki).
  • Lista pokazuje wszystkie statusy (nie tylko published) — admin musi widzieć też szkice i archiwum.
  • leftJoin('a.category')->addSelect('c') — dociągamy kategorię jednym zapytaniem, żeby lista nie generowała N+1.

Formularz artykułu

BlogArticleType odwzorowuje pola encji na formularz:

// src/Form/BlogArticleType.php (fragment buildForm)
$builder
    ->add('title', TextType::class)
    ->add('slug', TextType::class, ['required' => false])          // puste → wygenerujemy z tytułu
    ->add('excerpt', TextareaType::class, ['required' => false])
    ->add('content', TextareaType::class)
    ->add('category', EntityType::class, [
        'class'       => BlogCategory::class,
        'choice_label' => 'name',
        'required'    => false,
    ])
    ->add('tags', EntityType::class, [
        'class'       => BlogTag::class,
        'choice_label' => 'name',
        'multiple'    => true,
        'expanded'    => true,        // checkboxy
        'required'    => false,
    ])
    ->add('status', ChoiceType::class, [
        'choices' => [
            'Szkic'         => BlogArticle::STATUS_DRAFT,
            'Opublikowany'  => BlogArticle::STATUS_PUBLISHED,
            'Zarchiwizowany' => BlogArticle::STATUS_ARCHIVED,
            'Ukryty'        => BlogArticle::STATUS_HIDDEN,
        ],
    ])
    ->add('publishedAt', DateTimeType::class, ['required' => false, 'widget' => 'single_text']);
  • slug opcjonalny — jeśli admin nic nie wpisze, wygenerujemy go z tytułu (niżej).
  • category / tags przez EntityType — Symfony sam wczyta listę kategorii i tagów z bazy.
  • status jako ChoiceType — wartości to stałe STATUS_* z Rozdziału 3, etykiety po polsku.

Tworzenie i edycja (auto-slug, auto-publishedAt)

Akcje create i edit dzielą tę samą logikę: obsługa formularza + dwa automatyzmy — slug z tytułu i publishedAt przy publikacji:

#[Route('/create', name: 'article_create', methods: ['GET', 'POST'])]
public function articleCreate(Request $request, EntityManagerInterface $em): Response
{
    $article = new BlogArticle();
    $form = $this->createForm(BlogArticleType::class, $article);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $article->setUpdatedAt(new \DateTimeImmutable());

        // 1) Slug: puste pole → wygeneruj z tytułu (cocur/slugify)
        if ('' === (string) $article->getSlug() && '' !== (string) $article->getTitle()) {
            $article->setSlug((new Slugify())->slugify($article->getTitle()));
        }

        // 2) publishedAt: ustaw przy pierwszej publikacji
        if (BlogArticle::STATUS_PUBLISHED === $article->getStatus() && null === $article->getPublishedAt()) {
            $article->setPublishedAt(new \DateTimeImmutable());
        }

        $user = $this->getUser();
        $article->setAuthor($user instanceof User ? $user : null);

        $em->persist($article);
        $em->flush();

        $this->addFlash('success', 'Artykuł został zapisany.');

        return $this->redirectToRoute('admin_blog_index');
    }

    return $this->render('admin/blog/article_form.html.twig', ['form' => $form, 'article' => null]);
}

Edycja jest niemal identyczna — Symfony wstrzyknie artykuł po {id} (EntityValueResolver), pomijamy persist() (encja już istnieje), a autora zostawiamy bez zmian:

#[Route('/{id}/edit', name: 'article_edit', methods: ['GET', 'POST'])]
public function articleEdit(BlogArticle $article, Request $request, EntityManagerInterface $em): Response
{
    $form = $this->createForm(BlogArticleType::class, $article);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $article->setUpdatedAt(new \DateTimeImmutable());
        // ten sam auto-slug i auto-publishedAt co wyżej…
        $em->flush();

        $this->addFlash('success', 'Artykuł został zaktualizowany.');

        return $this->redirectToRoute('admin_blog_index');
    }

    return $this->render('admin/blog/article_form.html.twig', ['form' => $form, 'article' => $article]);
}
  • Auto-slug — cocur/slugify zamienia „Mój pierwszy wpis” na moj-pierwszy-wpis (obsługuje polskie znaki). Zainstaluj: docker compose exec app composer require cocur/slugify.
  • Auto-publishedAt — ustawiamy datę publikacji tylko raz, gdy status po raz pierwszy staje się published (późniejsze zapisy jej nie nadpisują).

Szablon article_form.html.twig renderuje formularz jednym {{ form(form) }} (możesz dołożyć motyw Bootstrap jak przy komentarzach z Rozdziału 7).


Usuwanie z ochroną CSRF

Usuwanie zmienia stan, więc idzie tylko przez POST i wymaga tokenu CSRF (nie da się usunąć artykułu zwykłym linkiem GET ani cudzym żądaniem):

#[Route('/{id}/delete', name: 'article_delete', methods: ['POST'])]
public function articleDelete(BlogArticle $article, Request $request, EntityManagerInterface $em): Response
{
    if ($this->isCsrfTokenValid('delete-article-' . $article->getId(), (string) $request->request->get('_token'))) {
        $em->remove($article);
        $em->flush();
        $this->addFlash('success', 'Artykuł został usunięty.');
    }

    return $this->redirectToRoute('admin_blog_index');
}

Na liście przycisk usuwania to mały formularz z tokenem:

<form method="post" action="{{ path('admin_blog_article_delete', { id: article.id }) }}"
      onsubmit="return confirm('Na pewno usunąć?')">
    <input type="hidden" name="_token" value="{{ csrf_token('delete-article-' ~ article.id) }}">
    <button class="btn btn-sm btn-outline-danger">Usuń</button>
</form>

Usunięcie artykułu kasuje też jego komentarze i media dzięki onDelete: CASCADE z Rozdziału 3 — baza sprząta powiązania sama.


Kategorie i tagi

Kategorie i tagi mają ten sam, prosty schemat CRUD — osobne trasy, formularze (BlogCategoryType, BlogTagType) i akcje. Przykład dodawania tagu:

#[Route('/tags/create', name: 'tag_create', methods: ['GET', 'POST'])]
public function tagCreate(Request $request, EntityManagerInterface $em): Response
{
    $tag = new BlogTag();
    $form = $this->createForm(BlogTagType::class, $tag);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $em->persist($tag);
        $em->flush();
        $this->addFlash('success', 'Tag został dodany.');

        return $this->redirectToRoute('admin_blog_tags');
    }

    return $this->render('admin/blog/tag_form.html.twig', ['form' => $form, 'tag' => null]);
}

Kategorie i tagi też usuwamy przez POST z tokenem CSRF (jak artykuły). Cały panel jest już chroniony ROLE_ADMIN z poziomu klasy, więc żadna z tych akcji nie jest dostępna dla zwykłego użytkownika.

Tłumaczenia kategorii (nazwy w innych językach) dołożymy do tego panelu w Rozdziale 15 — na razie kategoria ma nazwę i slug.


Podsumowanie

Panel admina domyka podstawowy blog:

  • ✅ osobna warstwa src/Controller/Admin/ pod /admin/blog, chroniona #[IsGranted('ROLE_ADMIN')] na klasie (+ access_control z Rozdz. 2),
  • ✅ ręczny CRUD artykułów (bez EasyAdmin) — lista wszystkich statusów, tworzenie, edycja, usuwanie,
  • ✅ formularze Symfony (BlogArticleType z EntityType/ChoiceType) + auto-slug (cocur/slugify) i auto-publishedAt,
  • ✅ usuwanie z tokenem CSRF (POST), kaskadowe czyszczenie komentarzy/mediów,
  • ✅ CRUD kategorii i tagów tym samym wzorcem,
  • ✅ działający stan: logujesz się jako admin, wchodzisz na /admin/blog i zarządzasz treścią, która od razu pojawia się na froncie.

To koniec Części I — masz w pełni działający blog: artykuły z listą i paginacją, strony artykułów z Markdown, kategorie i tagi, komentarze z moderacją oraz panel administracyjny.

W Części II (Rozdziały 9–13) wzbogacimy treść: kolorowanie składni kodu, obrazki, wideo i audio, upload mediów oraz eksport artykułu do PDF. Zaczniemy w Rozdziale 9 od osadzania kodu i podświetlania składni.

Narzędzia / paczki

Symfony Form cocur/slugify Security CSRF

Spis treści