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łaaccess_control: ^/adminz 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']);
slugopcjonalny — jeśli admin nic nie wpisze, wygenerujemy go z tytułu (niżej).category/tagsprzezEntityType— Symfony sam wczyta listę kategorii i tagów z bazy.statusjakoChoiceType— wartości to stałeSTATUS_*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/slugifyzamienia „Mój pierwszy wpis” namoj-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_controlz Rozdz. 2), - ✅ ręczny CRUD artykułów (bez EasyAdmin) — lista wszystkich statusów, tworzenie, edycja, usuwanie,
- ✅ formularze Symfony (
BlogArticleTypezEntityType/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/blogi 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.