Rozdział 10: Obrazki w artykule
Dodajemy obrazki na dwa sposoby: inline w treści Markdown oraz jako galerię powiązaną z artykułem
przez encję BlogMedia. Zadbamy o dostępność (alt text) i responsywność. W tym rozdziale budujemy encję
i wyświetlanie — sam upload w panelu admina dojdzie w Rozdziale 12.
Stan wejściowy: render Markdown (Rozdz. 5) i stylowanie
.blog-content(Rozdz. 9). Tu dokładamy encjęBlogMediai galerię na stronie artykułu.
Obrazki inline w Markdown
Najprostsza droga — obrazek w treści artykułu, składnią Markdown:

To działa od razu (render z Rozdziału 5), a wygląd zapewnia reguła z Rozdziału 9:
.blog-content img { max-width: 100%; border-radius: .5rem; margin: 1rem 0; }
max-width: 100%— obrazek nigdy nie wyjdzie poza kolumnę (responsywność).- Pliki trzymamy w
public/(np.public/images/blog/…) i odwołujemy się ścieżką absolutną od web roota — Nginx serwujepublic/bezpośrednio. - Tekst w nawiasach kwadratowych to
alt— opis dla czytników ekranu i gdy obrazek się nie wczyta.
Inline nadaje się do obrazków wplecionych w tekst. Do zestawu zdjęć powiązanych z artykułem lepsza jest
osobna galeria — encja BlogMedia.
Encja BlogMedia
Jeden artykuł może mieć wiele plików (obrazki, a w Rozdziale 11 też wideo/audio) z zachowaniem kolejności.
Dlatego trzymamy je w osobnej tabeli blog_media:
// src/Entity/BlogMedia.php
#[ORM\Entity(repositoryClass: BlogMediaRepository::class)]
#[ORM\Table(name: 'blog_media')]
#[ORM\Index(fields: ['article'], name: 'idx_blog_media_article_id')]
class BlogMedia
{
public const TYPE_IMAGE = 'image';
public const TYPE_VIDEO = 'video';
public const TYPE_AUDIO = 'audio';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: BlogArticle::class, inversedBy: 'media')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?BlogArticle $article = null;
#[ORM\Column(length: 10)]
private string $type = self::TYPE_IMAGE;
#[ORM\Column(length: 500)]
private ?string $path = null; // ścieżka względem public/
#[ORM\Column(length: 255, nullable: true)]
private ?string $originalName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $altText = null; // opis (alt) — dostępność
#[ORM\Column(options: ['default' => 0])]
private int $position = 0; // kolejność w galerii
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
}
Teraz dokładamy stronę odwrotną do BlogArticle — kolekcję media, którą odłożyliśmy w Rozdziale 3:
// src/Entity/BlogArticle.php — dodajemy kolekcję media
/** @var Collection<int, BlogMedia> */
#[ORM\OneToMany(targetEntity: BlogMedia::class, mappedBy: 'article', cascade: ['persist', 'remove'])]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $media;
public function __construct()
{
// … pozostałe kolekcje …
$this->media = new ArrayCollection();
}
onDelete: 'CASCADE'— usunięcie artykułu kasuje jego media na poziomie bazy.OrderBy(['position' => 'ASC'])— galeria zawsze w ustalonej kolejności.cascade: ['persist', 'remove']— media zapisują/usuwają się razem z artykułem.
Generujemy migrację tabeli blog_media:
docker compose exec app php bin/console make:migration
docker compose exec app php bin/console doctrine:migrations:migrate
Skąd wezmą się media? Na razie z fixtures (albo ręcznie w bazie) — formularz uploadu w panelu admina dobudujemy w Rozdziale 12. Przykładowy wpis w
BlogFixtures:$media = new BlogMedia(); $media->setArticle($article)->setType(BlogMedia::TYPE_IMAGE) ->setPath('images/blog/architektura.png')->setAltText('Schemat architektury')->setPosition(0); $manager->persist($media);
Galeria obrazków
Na stronie artykułu renderujemy tylko media typu image (wideo/audio dojdą w Rozdziale 11), każde jako
<figure> z opcjonalnym podpisem. Kolekcja article.media jest już posortowana po position:
{# templates/blog/show.html.twig — galeria pod treścią artykułu #}
{% set images = article.media|filter(m => m.type == 'image') %}
{% if images|length > 0 %}
<div class="mb-4">
{% for img in images %}
<figure class="figure w-100">
<img src="/{{ img.path }}" alt="{{ img.altText }}"
class="figure-img img-fluid rounded shadow-sm w-100"
style="max-height: 450px; object-fit: cover;"
onerror="this.parentElement.style.display='none'">
{% if img.altText %}
<figcaption class="figure-caption text-center">{{ img.altText }}</figcaption>
{% endif %}
</figure>
{% endfor %}
</div>
{% endif %}
article.media|filter(m => m.type == 'image')— z całej kolekcji wybieramy same obrazki.<figure>+<figcaption>— semantyczny obrazek z podpisem (opartym naaltText).src="/{{ img.path }}"— ścieżka jest względempublic/, więc dokładamy wiodący/.
Dostępność i responsywność
Kilka drobiazgów, które robią dużą różnicę:
alt="{{ img.altText }}"— zawsze ustawiamy atrybutalt. Dla obrazków dekoracyjnych może być pusty, dla treściowych — opisowy (czytniki ekranu, SEO, fallback).img-fluid(Bootstrap) +max-width: 100%— obrazek skaluje się do szerokości kolumny na każdym urządzeniu.object-fit: cover+max-height— miniatury galerii mają równą wysokość bez zniekształceń.onerror="this.parentElement.style.display='none'"— jeśli plik zniknął, chowamy całą figurę zamiast pokazywać „złamany” obrazek.
Podsumowanie
Artykuł może mieć obrazki na dwa sposoby:
- ✅ inline w Markdown (
) — serwowane zpublic/, responsywne przez.blog-content img, - ✅ encja
BlogMedia(typ, path, altText, position) + kolekcjamediadołożona doBlogArticle(OrderBy,onDelete: CASCADE) i migracjablog_media, - ✅ galeria —
figure/figcaption, filtrtype == 'image', kolejność poposition, - ✅ dostępność i responsywność —
alt,img-fluid,object-fit, fallbackonerror, - ✅ działający stan: obrazki z treści i galerii wyświetlają się na stronie artykułu (media na razie z fixtures — upload w Rozdziale 12).
W Rozdziale 11 rozszerzymy media o wideo i audio — te same BlogMedia, ale osadzone przez natywne
<video> i <audio>.