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 wtemplates/admin/, a trasy z prefiksem/admini nazwamiadmin_*. 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_ADMINdodamy 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 naDELETEprzez ukryte pole_method(wymagało to włączeniahttp_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 —
cascadedział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:

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

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/, trasyadmin_*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
activatedw formularzu admina (opcjawith_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].