Rozdział 12: Upload i zarządzanie mediami w adminie
Encja BlogMedia i wyświetlanie mamy z Rozdziałów 10–11, ale media pochodziły z fixtures. Teraz
dobudowujemy upload w panelu admina: bezpieczne przesyłanie plików (UploadedFile), walidację typu
i rozmiaru, unikalną nazwę pliku oraz powiązanie z artykułem i usuwanie.
Stan wejściowy: encja
BlogMediaz kolekcjąmedianaBlogArticle(Rozdz. 10) i panel admina (Rozdz. 8). Tu dokładamy formularz uploadu na stronie edycji artykułu.
Katalog uploadu
Pliki trzymamy w public/uploads/blog/ — dzięki temu Nginx serwuje je bezpośrednio (ścieżka path
w BlogMedia jest względem public/). Katalog przekazujemy do akcji parametrem przez #[Autowire]:
use Symfony\Component\DependencyInjection\Attribute\Autowire;
// argument akcji uploadu:
#[Autowire('%kernel.project_dir%/public/uploads/blog')] string $uploadDir
Do repozytorium commitujemy sam katalog (z plikiem .gitkeep), ale nie wgrane pliki:
# .gitignore
/public/uploads/blog/*
!/public/uploads/blog/.gitkeep
Formularz i walidacja
Tworzymy mały typ formularza z niemapowanym polem FileType i ograniczeniem File (typ MIME
i maksymalny rozmiar) — walidacja dzieje się zanim cokolwiek zapiszemy:
// src/Form/BlogMediaUploadType.php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\File;
class BlogMediaUploadType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('file', FileType::class, [
'label' => 'Plik (obraz / wideo mp4 / audio mp3)',
'mapped' => false, // pole nie jest powiązane z encją
'required' => true,
'constraints' => [
new File(
maxSize: '20M',
mimeTypes: ['image/*', 'video/mp4', 'audio/mpeg'],
mimeTypesMessage: 'Dozwolone: obrazy, wideo mp4, audio mp3.',
),
],
]);
}
}
mapped: false— plik nie odpowiada żadnej właściwości encji; odbierzemy go ręcznie w akcji.File(...)— Symfony sam odrzuci zły typ MIME i za duży plik z czytelnym komunikatem.image/*to wieloznacznik obejmujący wszystkie obrazy; wideo/audio ograniczamy do bezpiecznych formatów (mp4,mp3).
Akcja uploadu: bezpieczna nazwa i zapis
Akcja żyje w BlogAdminController (cały panel jest już chroniony ROLE_ADMIN z Rozdziału 8). Po walidacji
generujemy bezpieczną, unikalną nazwę (SluggerInterface + uniqid()), przenosimy plik i tworzymy wpis
BlogMedia:
use App\Entity\BlogMedia;
use App\Form\BlogMediaUploadType;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;
#[Route('/{id}/media', name: 'media_upload', methods: ['POST'])]
public function uploadMedia(
BlogArticle $article,
Request $request,
EntityManagerInterface $em,
SluggerInterface $slugger,
#[Autowire('%kernel.project_dir%/public/uploads/blog')] string $uploadDir,
): Response {
$form = $this->createForm(BlogMediaUploadType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile $file */
$file = $form->get('file')->getData();
// bezpieczna, unikalna nazwa: slug oryginału + uniqid + rozszerzenie z MIME
$original = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$safeName = $slugger->slug($original)->lower();
$filename = sprintf('%s-%s.%s', $safeName, uniqid(), $file->guessExtension());
$file->move($uploadDir, $filename);
$media = new BlogMedia();
$media->setArticle($article)
->setType($this->mediaType((string) $file->getClientMimeType()))
->setPath('uploads/blog/' . $filename)
->setOriginalName($file->getClientOriginalName())
->setPosition(\count($article->getMedia())); // dołóż na koniec galerii
$em->persist($media);
$em->flush();
$this->addFlash('success', 'Plik został dodany.');
}
return $this->redirectToRoute('admin_blog_article_edit', ['id' => $article->getId()]);
}
private function mediaType(string $mime): string
{
return match (true) {
str_starts_with($mime, 'video/') => BlogMedia::TYPE_VIDEO,
str_starts_with($mime, 'audio/') => BlogMedia::TYPE_AUDIO,
default => BlogMedia::TYPE_IMAGE,
};
}
$slugger->slug($original)->lower()— z „Mój Plik.JPG” robimoj-plik, bez spacji i znaków specjalnych (bezpieczna nazwa w systemie plików i URL-u).uniqid()gwarantuje unikalność — dwa pliki o tej samej nazwie się nie nadpiszą.guessExtension()wybiera rozszerzenie na podstawie realnego typu MIME, nie ufając nazwie od użytkownika.typewykrywamy z MIME (obraz/wideo/audio) — dokładnie te typy, które renderujemy w Rozdziałach 10–11.position = count(media)— nowy plik ląduje na końcu galerii.
Zarządzanie mediami
Na stronie edycji artykułu (Rozdział 8) dokładamy sekcję: listę wgranych mediów z przyciskiem usuwania oraz formularz uploadu:
{# admin/blog/article_form.html.twig — sekcja mediów (tylko w edycji) #}
{% if article %}
<h3 class="h6 mt-4">Media</h3>
<div class="d-flex flex-wrap gap-2 mb-3">
{% for m in article.media %}
<div class="border rounded p-2 small">
{{ m.type }} — {{ m.originalName }}
<form method="post" action="{{ path('admin_blog_media_delete', { id: m.id }) }}" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('delete-media-' ~ m.id) }}">
<button class="btn btn-sm btn-link text-danger p-0">usuń</button>
</form>
</div>
{% endfor %}
</div>
{{ form_start(uploadForm, { action: path('admin_blog_media_upload', { id: article.id }) }) }}
{{ form_row(uploadForm.file) }}
<button class="btn btn-secondary btn-sm">Wgraj plik</button>
{{ form_end(uploadForm) }}
{% endif %}
Akcja usuwania (POST + token CSRF, jak przy artykułach) kasuje wpis i plik z dysku:
#[Route('/media/{id}/delete', name: 'media_delete', methods: ['POST'])]
public function deleteMedia(
BlogMedia $media,
Request $request,
EntityManagerInterface $em,
#[Autowire('%kernel.project_dir%/public')] string $publicDir,
): Response {
$articleId = $media->getArticle()?->getId();
if ($this->isCsrfTokenValid('delete-media-' . $media->getId(), (string) $request->request->get('_token'))) {
@unlink($publicDir . '/' . $media->getPath()); // usuń plik z dysku
$em->remove($media);
$em->flush();
$this->addFlash('success', 'Plik został usunięty.');
}
return $this->redirectToRoute('admin_blog_article_edit', ['id' => $articleId]);
}
Akcja articleEdit z Rozdziału 8 musi jeszcze przekazać uploadForm do szablonu:
'uploadForm' => $this->createForm(BlogMediaUploadType::class)->createView(),
Podsumowanie
Panel admina zarządza teraz mediami:
- ✅ katalog
public/uploads/blog/(#[Autowire],.gitignorena pliki), - ✅ formularz
FileType(mapped: false) z walidacjąFile(typ MIME +maxSize), - ✅ bezpieczna, unikalna nazwa (
SluggerInterface+uniqid()+guessExtension()) imove(), - ✅ wpis
BlogMediaz typem wykrytym z MIME, ścieżką iposition; usuwanie z tokenem CSRF (plik + wpis), - ✅ działający stan: w edycji artykułu wgrywasz obraz/wideo/audio i od razu pojawia się w galerii na froncie (Rozdziały 10–11).
W Rozdziale 13 — ostatnim w Części II — dodamy eksport artykułu do PDF przez Spatie Browsershot (headless Chromium), z zachowaniem kolorowania kodu i obrazków.