10

Dzień 10 z 18

Panel administracyjny

Opublikowany

Budujemy panel administracyjny jako osobną sekcję aplikacji — ręcznie, zgodnie z konwencją projektu. Pełny CRUD kategorii i ofert, własny layout, usuwanie przez POST + CSRF, paginacja Pagerfanta i podgląd logo w formularzu.

Czego się nauczysz

  • Osobna sekcja admina: src/Controller/Admin, templates/admin, trasy admin_*
  • Layout panelu z aktywnym lewym menu (starts with)
  • CRUD kategorii: lista, tworzenie, edycja
  • CRUD ofert z formularzem i polem activated
  • Usuwanie przez POST + token CSRF (isCsrfTokenValid)
  • Paginacja listy ofert (Pagerfanta)
  • Podgląd logo przez nadpisanie bloku motywu formularza

Co zmieniło się w Symfony 8 vs 4.2

  • Atrybuty #[Route] i prefiks na poziomie klasy zamiast adnotacji
  • Usuwanie POST + isCsrfTokenValid zamiast podmiany metody _method=DELETE
  • Pagerfanta (jak w projekcie) zamiast KnpPaginatorBundle
  • EntityValueResolver zamiast @ParamConverter

Dzień 10: Panel administracyjny

Frontend Jobeet jest już w pełni używalny. Czas na panel administracyjny — miejsce, gdzie admin zarządza kategoriami i ofertami (tworzy, edytuje, usuwa). Zbudujemy go ręcznie, jako osobną, wydzieloną sekcję aplikacji — dokładnie tak, jak robi to ten projekt w module bloga.

Panel trzymamy osobno: kontrolery w src/Controller/Admin/, szablony w templates/admin/, a trasy z prefiksem /admin i nazwami admin_*. Usuwanie robimy przez POST + token CSRF (isCsrfTokenValid), paginację przez Pagerfanta.

Zmiana vs Symfony 4.2: oryginał budował CRUD ręcznie i kończył wzmianką o gotowych bundlach (EasyAdmin, Sonata). My również budujemy CRUD ręcznie — bo tak robi ten projekt i daje to pełną kontrolę nad wyglądem (Bootstrap 5) i logiką. Zabezpieczenie panelu rolą ROLE_ADMIN dodamy w Dniu 11.


Layout panelu admina

Panel ma własny layout (lewe menu z sekcjami, inny nagłówek). Utwórz templates/admin/base.html.twig:

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}Jobeet — Panel administracyjny{% 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 %}
</head>
<body>
    <nav class="navbar navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand fw-bold" href="{{ path('admin_job_index') }}">Jobeet — Admin</a>
        </div>
    </nav>

    <div class="container my-4">
        <div class="row g-4">
            <div class="col-md-3">
                {% set route = app.request.get('_route') %}
                <div class="list-group">
                    <a class="list-group-item list-group-item-action {{ route starts with 'admin_category_' ? 'active' }}"
                       href="{{ path('admin_category_index') }}">Kategorie</a>
                    <a class="list-group-item list-group-item-action {{ route starts with 'admin_job_' ? 'active' }}"
                       href="{{ path('admin_job_index') }}">Oferty</a>
                </div>
            </div>

            <div class="col-md-9">
                {% for message in app.flashes('success') %}
                    <div class="alert alert-success">{{ message }}</div>
                {% endfor %}

                {% block body %}{% endblock %}
            </div>
        </div>
    </div>
</body>
</html>

Aktywny element menu wykrywamy operatorem starts with na nazwie bieżącej trasy (_route).


CRUD kategorii

Kontroler i lista

Utwórz src/Controller/Admin/CategoryController.php. Prefiks trasy i nazwy ustawiamy na poziomie klasy:

<?php

namespace App\Controller\Admin;

use App\Entity\Category;
use App\Repository\CategoryRepository;
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;

#[Route('/admin/categories', name: 'admin_category_')]
final class CategoryController extends AbstractController
{
    #[Route('', name: 'index', methods: ['GET'])]
    public function index(CategoryRepository $categories): Response
    {
        return $this->render('admin/category/index.html.twig', [
            'categories' => $categories->findBy([], ['name' => 'ASC']),
        ]);
    }
}

Szablon templates/admin/category/index.html.twig:

{% extends 'admin/base.html.twig' %}

{% block body %}
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h1 class="h3 mb-0">Kategorie</h1>
        <a href="{{ path('admin_category_create') }}" class="btn btn-success">
            <i class="fa-solid fa-plus me-1"></i>Dodaj kategorię
        </a>
    </div>

    <table class="table table-hover align-middle">
        <thead>
            <tr>
                <th>Nazwa</th>
                <th>Slug</th>
                <th class="text-center">Oferty</th>
                <th class="text-end">Akcje</th>
            </tr>
        </thead>
        <tbody>
            {% for category in categories %}
                <tr>
                    <td>{{ category.name }}</td>
                    <td><code>{{ category.slug }}</code></td>
                    <td class="text-center">{{ category.jobs|length }}</td>
                    <td class="text-end text-nowrap">
                        <a href="{{ path('admin_category_edit', { id: category.id }) }}" class="btn btn-sm btn-outline-secondary">Edytuj</a>
                        <form method="post" action="{{ path('admin_category_delete', { id: category.id }) }}"
                              class="d-inline" onsubmit="return confirm('Usunąć tę kategorię?')">
                            <input type="hidden" name="_token" value="{{ csrf_token('delete-category-' ~ category.id) }}">
                            <button class="btn btn-sm btn-outline-danger">Usuń</button>
                        </form>
                    </td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
{% endblock %}

Formularz kategorii

Formularze admina trzymamy w src/Form/Admin/. Utwórz src/Form/Admin/CategoryType.php — jedno pole name (slug policzy się automatycznie w encji, z Dnia 7):

<?php

namespace App\Form\Admin;

use App\Entity\Category;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

class CategoryType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class, [
            'label' => 'Nazwa',
            'constraints' => [new NotBlank(), new Length(max: 100)],
        ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Category::class]);
    }
}

Renderowanie formularza wydziel od razu do współdzielonego templates/admin/category/_form.html.twig:

{{ form_start(form) }}
    {{ form_widget(form) }}
    <button type="submit" class="btn btn-primary mt-3">Zapisz</button>
{{ form_end(form) }}

<a href="{{ path('admin_category_index') }}" class="btn btn-link">
    <i class="fa-solid fa-arrow-left me-1"></i>Powrót do listy
</a>

Tworzenie i edycja

Obie akcje korzystają z tego samego formularza i partiala. Dopisz do CategoryController:

use App\Form\Admin\CategoryType;

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

    if ($form->isSubmitted() && $form->isValid()) {
        $em->persist($category);
        $em->flush();

        $this->addFlash('success', 'Kategoria została dodana.');

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

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

#[Route('/{id}/edit', name: 'edit', methods: ['GET', 'POST'], requirements: ['id' => '\d+'])]
public function edit(Request $request, Category $category, EntityManagerInterface $em): Response
{
    $form = $this->createForm(CategoryType::class, $category);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $em->flush(); // obiekt już istnieje — bez persist()

        $this->addFlash('success', 'Kategoria została zaktualizowana.');

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

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

Szablon templates/admin/category/form.html.twig:

{% extends 'admin/base.html.twig' %}

{% block body %}
    <h1 class="h3 mb-4">{{ form.vars.data.id ? 'Edycja kategorii' : 'Nowa kategoria' }}</h1>

    {% include 'admin/category/_form.html.twig' with { form: form } only %}
{% endblock %}

W edycji nie wołamy persist() — encja jest już śledzona przez EntityManagera, wystarczy flush().

Usuwanie

Usuwanie to formularz POST z tokenem CSRF (jak w liście powyżej). Dopisz akcję:

#[Route('/{id}/delete', name: 'delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function delete(Request $request, Category $category, EntityManagerInterface $em): Response
{
    if ($this->isCsrfTokenValid('delete-category-' . $category->getId(), (string) $request->request->get('_token'))) {
        $em->remove($category);
        $em->flush();

        $this->addFlash('success', 'Kategoria została usunięta.');
    }

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

Zmiana vs Symfony 4.2: usuwamy metodą POST z walidacją isCsrfTokenValid(), zamiast podmiany metody na DELETE przez ukryte pole _method (wymagało to włączenia http_method_override). To podejście stosuje też moduł bloga w projekcie.

Jeśli kategoria ma powiązane oferty, usunięcie zwróci błąd (Doctrine nie wie, co zrobić z relacją). Aby usuwać kaskadowo, dodaj cascade: ['remove'] do relacji w src/Entity/Category.php:

#[ORM\OneToMany(mappedBy: 'category', targetEntity: Job::class, cascade: ['remove'])]
private Collection $jobs;

Migracja nie jest potrzebna — cascade działa na poziomie ORM, nie bazy.


CRUD ofert

Analogicznie budujemy panel ofert, z dwiema różnicami: paginacją (ofert może być dużo) oraz podglądem logo w formularzu.

Formularz admina — pole „activated”

Admin, w przeciwieństwie do użytkownika, może aktywować/dezaktywować ofertę. Rozszerzmy JobType z Dnia 8 o opcjonalne pole activated sterowane opcją formularza:

// src/Form/JobType.php — w configureOptions()
public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'data_class' => Job::class,
        'with_activated' => false,
    ]);
    $resolver->setAllowedTypes('with_activated', 'bool');
}

// w buildForm(), na końcu:
if ($options['with_activated']) {
    $builder->add('activated', ChoiceType::class, [
        'label' => 'Aktywna?',
        'choices' => ['Tak' => true, 'Nie' => false],
        'constraints' => [new NotNull()],
    ]);
}

Kontroler, lista z paginacją

Utwórz src/Controller/Admin/JobController.php:

<?php

namespace App\Controller\Admin;

use App\Entity\Job;
use App\Form\JobType;
use App\Repository\JobRepository;
use App\Service\FileUploader;
use Doctrine\ORM\EntityManagerInterface;
use Pagerfanta\Doctrine\ORM\QueryAdapter;
use Pagerfanta\Exception\OutOfRangeCurrentPageException;
use Pagerfanta\Pagerfanta;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/admin/jobs', name: 'admin_job_')]
final class JobController extends AbstractController
{
    private const int PER_PAGE = 10;

    #[Route('', name: 'index', methods: ['GET'])]
    public function index(Request $request, JobRepository $jobs): Response
    {
        $qb = $jobs->createQueryBuilder('j')->orderBy('j.createdAt', 'DESC');
        $pager = new Pagerfanta(new QueryAdapter($qb));
        $pager->setMaxPerPage(self::PER_PAGE);

        try {
            $pager->setCurrentPage($request->query->getInt('page', 1));
        } catch (OutOfRangeCurrentPageException) {
            throw new NotFoundHttpException();
        }

        return $this->render('admin/job/index.html.twig', ['pager' => $pager]);
    }
}

Szablon templates/admin/job/index.html.twig (z nawigacją paginacji Bootstrap 5):

{% extends 'admin/base.html.twig' %}

{% block body %}
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h1 class="h3 mb-0">Oferty</h1>
        <a href="{{ path('admin_job_create') }}" class="btn btn-success">
            <i class="fa-solid fa-plus me-1"></i>Dodaj ofertę
        </a>
    </div>

    <table class="table table-hover align-middle">
        <thead>
            <tr>
                <th>Firma</th>
                <th>Stanowisko</th>
                <th>Lokalizacja</th>
                <th class="text-center">Aktywna</th>
                <th class="text-end">Akcje</th>
            </tr>
        </thead>
        <tbody>
            {% for job in pager %}
                <tr>
                    <td>{{ job.company }}</td>
                    <td>{{ job.position }}</td>
                    <td>{{ job.location }}</td>
                    <td class="text-center">
                        {% if job.activated %}
                            <i class="fa-solid fa-check text-success"></i>
                        {% else %}
                            <i class="fa-solid fa-xmark text-muted"></i>
                        {% endif %}
                    </td>
                    <td class="text-end text-nowrap">
                        <a href="{{ path('admin_job_edit', { id: job.id }) }}" class="btn btn-sm btn-outline-secondary">Edytuj</a>
                        <form method="post" action="{{ path('admin_job_delete', { id: job.id }) }}"
                              class="d-inline" onsubmit="return confirm('Usunąć tę ofertę?')">
                            <input type="hidden" name="_token" value="{{ csrf_token('delete-job-' ~ job.id) }}">
                            <button class="btn btn-sm btn-outline-danger">Usuń</button>
                        </form>
                    </td>
                </tr>
            {% endfor %}
        </tbody>
    </table>

    {% if pager.haveToPaginate %}
        <nav class="d-flex justify-content-center">
            <ul class="pagination">
                {% for p in range(1, pager.nbPages) %}
                    <li class="page-item {{ p == pager.currentPage ? 'active' }}">
                        <a class="page-link" href="{{ path('admin_job_index', { page: p }) }}">{{ p }}</a>
                    </li>
                {% endfor %}
            </ul>
        </nav>
    {% endif %}
{% endblock %}

Tworzenie i edycja z podglądem logo

Formularz ofert renderujemy przez współdzielony partial. Kluczowa rzecz: w edycji chcemy pokazać aktualne logo nad polem uploadu. Wykorzystamy do tego nadpisanie bloku motywu formularza.

templates/admin/job/_form.html.twig:

{% form_theme form _self %}

{% block _job_logo_widget %}
    {% set job = form.parent.vars.data %}
    {% if job.logo %}
        <img src="{{ asset('uploads/jobs/' ~ job.logo) }}" alt="{{ job.company }}"
             class="d-block mb-2" style="max-height:100px">
    {% endif %}
    {{ form_widget(form) }}
{% endblock %}

{{ form_start(form) }}
    {{ form_widget(form) }}
    <button type="submit" class="btn btn-primary mt-3">Zapisz</button>
{{ form_end(form) }}

Dzięki {% form_theme form _self %} Twig szuka nadpisanych bloków formularza w tym samym pliku. Blok _job_logo_widget nadpisuje renderowanie pola logo formularza job: form wewnątrz bloku to samo pole logo, a form.parent.vars.data to encja Job — czytamy z niej nazwę pliku i pokazujemy obrazek.

Przed dodaniem podglądu formularz nie sygnalizuje, czy oferta ma logo:

Formularz edycji oferty — pole logo bez podglądu

Po nadpisaniu bloku widać miniaturę aktualnego logo nad polem uploadu:

Formularz edycji oferty — logo widoczne

Szablony stron (templates/admin/job/form.html.twig):

{% extends 'admin/base.html.twig' %}

{% block body %}
    <h1 class="h3 mb-4">{{ form.vars.data.id ? 'Edycja oferty' : 'Nowa oferta' }}</h1>

    {% include 'admin/job/_form.html.twig' with { form: form } only %}

    <a href="{{ path('admin_job_index') }}" class="btn btn-link">
        <i class="fa-solid fa-arrow-left me-1"></i>Powrót do listy
    </a>
{% endblock %}

Akcje kontrolera (obsługa uploadu przez serwis FileUploader z Dnia 8, formularz z opcją with_activated):

#[Route('/create', name: 'create', methods: ['GET', 'POST'])]
public function create(Request $request, EntityManagerInterface $em, FileUploader $fileUploader): Response
{
    $job = new Job();
    $form = $this->createForm(JobType::class, $job, ['with_activated' => true]);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $logoFile = $form->get('logo')->getData();
        if ($logoFile instanceof UploadedFile) {
            $job->setLogo($fileUploader->upload($logoFile));
        }

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

        $this->addFlash('success', 'Oferta została dodana.');

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

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

#[Route('/{id}/edit', name: 'edit', methods: ['GET', 'POST'], requirements: ['id' => '\d+'])]
public function edit(Request $request, Job $job, EntityManagerInterface $em, FileUploader $fileUploader): Response
{
    $form = $this->createForm(JobType::class, $job, ['with_activated' => true]);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $logoFile = $form->get('logo')->getData();
        if ($logoFile instanceof UploadedFile) {
            $job->setLogo($fileUploader->upload($logoFile));
        }

        $em->flush();

        $this->addFlash('success', 'Oferta została zaktualizowana.');

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

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

Usuwanie oferty

#[Route('/{id}/delete', name: 'delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function delete(Request $request, Job $job, EntityManagerInterface $em): Response
{
    if ($this->isCsrfTokenValid('delete-job-' . $job->getId(), (string) $request->request->get('_token'))) {
        $em->remove($job);
        $em->flush();

        $this->addFlash('success', 'Oferta została usunięta.');
    }

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

Podsumowanie

Mamy kompletny panel administracyjny:

  • ✅ osobna sekcja: src/Controller/Admin/, templates/admin/, trasy admin_* z prefiksem /admin,
  • ✅ własny layout z aktywnym lewym menu (starts with),
  • ✅ pełny CRUD kategorii i ofert (lista, tworzenie, edycja, usuwanie),
  • ✅ usuwanie przez POST + CSRF (isCsrfTokenValid) i kaskadowe usuwanie ofert,
  • ✅ paginacja listy ofert (Pagerfanta),
  • ✅ podgląd logo w formularzu przez nadpisanie bloku motywu,
  • ✅ pole activated w formularzu admina (opcja with_activated).

Panel jest na razie niezabezpieczony — dostępny dla każdego. W Dniu 11 dodamy logowanie, encję User, role i ograniczymy /admin do ROLE_ADMIN atrybutem #[IsGranted].

Narzędzia / paczki

Symfony Form Pagerfanta Bootstrap 5

Spis treści