Symfony bundle providing photo galleries on the c975L core — categories and medias (photos and videos from any platform UiBundle declares), with batch upload, automatic thumb/medium/highres derivatives and a public viewer.
Add GalleryBundle on top of c975L/CoreBundle (ConfigBundle + UiBundle, one package) and get a photo gallery — no dependency on SiteBundle, ShopBundle or any other satellite bundle, so it drops into any c975L site that needs one. Multi-size derivatives reuse UiBundle's own VichMultiSizeImageInterface pattern rather than duplicating it.
TL;DR — Photo galleries as
GalleryCategory→GalleryMedia, managed from EasyAdmin, with bulk upload and automatic thumb/medium/highres derivatives. The category is the top-level unit: a site's galleries are its categories. Depends only on CoreBundle.
- Setup — requirements · installation · configuration · routes · assets · theme
- Using it — public routes · linking from a menu · renaming a category · deleting a gallery · uploading a batch · renaming a media · browsing and the lightbox · editing from the public pages · blocks · category description · category headings · theme tokens · videos · deleting a selection · credits / rights on a selection · export / import categories · sitemap and health check · backup · what's new
- Operating — bringing an existing gallery in · upload ceilings
GalleryCategory→GalleryMedia: the category is the top-level unit, a site's galleries being its categories - no container above them.- Bulk upload: pick every file at once from the category they belong to, with a title root, credits and rights-reserved applied to the whole batch, retouched one media at a time afterwards. The same batch is offered on the category creation form, so a category is created with its medias in one go. Optionally, the untouched originals are kept outside the document root (see uploading a batch).
- Three derivatives generated automatically per uploaded image (thumbnail / medium / highres), all three holding the whole photo, via UiBundle's
VichImageResizeListenerand theVichMultiSizeImageInterfacecontract - naming and resizing stay centralized in UiBundle, this bundle only declares the target sizes and how its grids frame them (see Thumbnail framing). - One EasyAdmin menu entry ("Gallery", opening the categories, with their media count); a category's medias are listed under its own edit form, each thumbnail opening the media it stands for, and medias are added from the category itself.
- Each media in that list carries a checkbox, so a selection of them is deleted in one go instead of one edit screen at a time (see deleting a selection), or given the same credits and rights at once (see credits / rights on a selection).
- A catch-all "Non classé" category is created lazily so an imported media always has one, even without a real one to attach it to.
- A public front-office viewer (index → category → media), browsed entirely in the stored (medium) resolution, with circular previous/next navigation whose neighbouring images are preloaded in the background so switching medias never shows a blank image while it loads. The high resolution opens in a lightbox over the image, fetched only when the visitor asks for it (see browsing and the lightbox).
- Two block kinds contributed to UiBundle, so a gallery can be shown on any page composed in the back office instead of only under its own routes (see blocks).
- A category owns UiBundle blocks of its own, giving it an editorial heading above its grid (see category headings).
- A category carries a rich-text description, printed above its grid and reused as the page's social/search metas (see description).
- Videos sit in the same categories as the photos: an entry becomes one by carrying the url of the page it is watched on, or a video file of the site's own, and each carries its own uploaded still, so one grid holds both kinds. YouTube, TikTok, Vimeo and Dailymotion are recognized, any other player being framed as pasted (see videos).
- The bundle's own stylesheet and theme file, reading UiBundle's admin-editable colors and fonts, so a gallery looks like the site it is installed on without a line of CSS (see theme).
- Sitemap generation (gallery index, categories and media pages), via ConfigBundle's
SitemapProviderInterface - The gallery index and each category offered as a SiteBundle menu target, so a navbar links straight to one of the site's galleries (see linking a gallery from a menu)
- Categories can be exported/imported as a zip (heading blocks, medias and files bundled in), plugging into ConfigBundle's Export sync (everything) dashboard shortcut and Import content screen.
- The two upload roots declared to the backup, via ConfigBundle's
BackupPathProviderInterface, mirrored offsite rather than tarred (see backup)
- PHP >= 8.4
- Symfony ^8.0
- c975L/CoreBundle — ConfigBundle and UiBundle ship as the single
c975l/core-bundlepackage, so requiring this bundle pulls both (Vich naming/resizing, EasyAdmin form-theme conventions, stylesheet registry, page layout fallback, menu provider, scaffold, sitemap and health checks) - Doctrine ORM
- EasyAdmin
- VichUploader Bundle
symfony/expression-language, which the public routes' condition is evaluated with (see public routes) — pulled in by Composer
GalleryMedia::$user is typed against c975L\ConfigBundle\Contract\UserInterface: your App\Entity\User must implement it. The scaffolded User already does; an older one adds the implements itself, with no migration and no configuration change.
composer require c975l/gallery-bundlephp bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migratephp bin/console c975l:config:load-allSeeds the bundle's config/configs.json into ConfigBundle's Configuration screen, under a Gallery
group of its own. It holds the gallery's url prefix, editable from there like every
other setting of this ecosystem — nothing of this bundle is configured in the app's yaml.
Add the bundle's public routes to config/routes.yaml:
c975_l_gallery:
resource: "@c975LGalleryBundle/src/Controller/"
type: attribute
prefix: /Templates {% extends 'layout.html.twig' %}, same convention as this ecosystem's other public-facing
bundles (e.g. BookBundle) - override any of them from your app's
templates/bundles/c975LGalleryBundle/.
php bin/console assets:install --symlinkNothing to register by hand, front or back. The bundle ships two Stimulus entrypoints, each starting its
own app: controllers.js for the public pages (previous/next preloading, the high-resolution lightbox,
the right click/drag blocking) and controllers-admin.js for the back office (the upload screen's batch
check, see upload ceilings). Both are auto-registered through UiBundle's script
registry, and their importmap.php entries are written by ImportmapProvider the first time you
composer update after installing the bundle — php bin/console c975l:config:check-importmap reports
any that is missing.
php bin/console c975l:scaffold:installCopies assets/styles/themes/gallery.css into the app, where it is owned from then on (see
theme).
| Route | URL | Description |
|---|---|---|
gallery_index |
/gallery |
Gallery index, one thumbnail per category |
gallery_category |
/gallery/{category} |
Category grid, photos and videos alike |
gallery_media |
/gallery/{category}/{slug} |
Media: photo in medium resolution, or video embed |
The first segment is the Gallery url prefix setting (gallery-route-prefix, group Gallery in
Configuration), so a site serves these routes in its own language — galerie, fotos — renamed from
the dashboard, with no yaml and no cache to clear: the change applies on the very next request.
A route path is compiled into the router's cache, so the prefix can't be the path: the three routes are
declared as /{gallery_prefix}/…, carrying it as a route parameter instead, and each of the three
routes carries a condition asking Routing\GalleryRoutePrefix whether the segment it was handed is the
configured one. Any other value simply doesn't match, and the router carries on with the rest of the
site's routes — without that check, /{gallery_prefix}/{category} would swallow every two-segment url of
the site. Generating a url is the mirror image: Listener\GalleryRoutePrefixListener puts the configured
prefix in the router's request context, which is where the generator takes a route parameter it wasn't
given from, so path('gallery_category', {category: ...}) keeps taking the category alone.
Leading and trailing slashes are ignored, and an empty value falls back to gallery rather than mounting
the category route at the site root — as does a prefix not configured at all, before
c975l:config:load-all has run. GallerySitemapProvider reads the same service, so the declared urls
always match what the router serves; it does so directly rather than through the generator, the sitemap
being written from the command line where no request has filled the context. Route names never change,
whatever the prefix.
Renaming the prefix breaks the previous urls, which then 404. If they were indexed, declare a redirect — ConfigBundle's Redirections screen takes one.
Management\LinkableRouteProvider offers these routes to SiteBundle's menus (Menus in the dashboard,
navbar / footer / email header / email footer): the target select of a menu item lists the gallery
index alongside the site's pages, and one entry per category — the categories being the site's
galleries, an item usually points straight at one of them.
A category is listed there as Galerie - Paysages, so the galleries are found at a glance among every page of the site and sit together once the list is sorted; the rendered navbar item reads Paysages, the category's own title, the prefix being of no use in a bar.
A category entry is keyed on the category's id, so renaming the category, changing its slug or renaming the route prefix leaves no menu item behind: only the target is stored, the url being generated at each render and the label read from the category's own title. Deleting a category simply drops its items from the rendered menu, as it does for any target that no longer resolves.
The item's own label field overrides that title, for a category whose name is too long to sit in a navbar.
A category's slug is what /{prefix}/{category} is built from, so renaming a category moves its public
url. The title field asks for confirmation before it takes a single keystroke (UiBundle's title-confirm
controller, over EasyAdmin's own confirmation modal), and the slug is then rebuilt from the new title —
EasyAdmin's SlugField stops following its target field as soon as the slug holds a value, so on an edit
form nothing would resync it otherwise. Editing the slug by hand stays possible through the padlock, and a
slug already taken is refused by the form rather than silently suffixed.
Either way, the old url is not left to 404: GalleryCategoryCrudController::updateEntity() writes a
permanent redirect to the new one, in ConfigBundle's Redirections, reusing the row a previous rename
left behind rather than piling them up. Renaming a category back to what it was drops the redirect that
would otherwise point the other way, so the two never loop. This mirrors what SiteBundle does for a page.
The category's slug is also the segment above each of its medias, so a rename moves their urls too: a
second, wildcarded row (/{prefix}/{old-slug}/*, ConfigBundle's own convention) sends them to the renamed
category rather than leaving each media to 404.
A category is deleted from its own edit screen as well as from the listing's row button, EasyAdmin's own
confirmation modal standing in the way either way. It takes everything under it along: its medias —
their three derivatives and any kept original with them, Listener\GalleryMediaDerivativeCleanupListener
removing the files — and its heading blocks. The directory the category grouped its files under goes too,
in public/ and in private/, once it is actually empty.
The catch-all "Non classé" category shows no delete button anywhere: it is what a media uploaded
without a real category falls back to, so it has to survive (GalleryCategory::$uncategorized, a flag
rather than a slug, so translating or editing its title changes nothing).
Medias are only ever added in bulk, from the category they belong to — the upload screen
(GalleryMediaBatchUploadType) and the category creation form, which fills a category as it is created,
offer the same fields and go through the same GalleryMediaFactory. Four of them apply to the whole batch:
| Field | What it does |
|---|---|
| Title root | Titles every media {root} 1, {root} 2… numbered from where the category leaves off, so a second batch continues the series. Left empty, each title falls back to its own filename (IMG_1234 → Img 1234). |
| Credits | The same credits line on every media of the batch. |
| Rights reserved | The same rights state on every media of the batch. |
| Keep the originals | Copies each untouched upload aside — see below. |
The title root does not seed the slug. A number reads as an order, and the order is the one thing a
gallery changes: reorder the medias and cailloux-couleur-3 sits fifth. The slug takes six hex characters
instead, hashed from the photo's EXIF capture date (DateTimeOriginal) — intrinsic to the photo, so the
url survives a reordering and a retitling alike:
Cailloux couleur 3 → /photos/mineraux/cailloux-couleur-a1b2c3
The date itself never appears — when a photo was shot is nobody's business, only its stability is wanted.
Without EXIF (a scan, a screenshot, a stripped file, or no ext-exif installed) the seed falls back to the
filename and the rank in the batch; the hash is what makes that safe, nothing but hex reaching the url
whatever the browser sent. Two shots taken in the same second hash alike and the second is suffixed -2,
exactly as two identical filenames are.
GalleryMedia is a VichOriginalKeepableInterface (UiBundle). With the box checked, the uploaded file is
copied to private/medias/gallery/{category}/{media}-{uniqid}-original.{ext} — the same base name and the
same directory structure as the derivatives, one root over — before UiBundle's VichImageResizeListener
overwrites it in place with its own downscaled webp. That is the only moment the upload still exists as it
was sent.
The extension is the only part not derived from internal values, so it is decided on the mime type read
off the file's own bytes, against an allow-list (jpg, png, gif, webp, tif). A type off that
list is not kept at all rather than copied under an extension guessed from the name the browser sent —
which is client input that would otherwise land on disk as a path. The four files of a media therefore read
as one set, the original being the only one that is not a webp:
public/medias/gallery/mineraux/cailloux-a1b2c3.webp ← the medium served
public/medias/gallery/mineraux/cailloux-a1b2c3-thumb.webp
public/medias/gallery/mineraux/cailloux-a1b2c3-highres.webp
private/medias/gallery/mineraux/cailloux-a1b2c3-original.jpg ← the untouched upload
private/ is outside the document root, so nothing serves them: they are kept so a media can be
re-processed later (a new target width, a new format) without a re-upload. GalleryMedia::$originalFilename
records the path and doubles as the answer to "does this media have an original" — a media whose file is
replaced later goes on keeping one, the box only ever being answered at upload time. Deleting a media
removes it along with the derivatives (GalleryMediaDerivativeCleanupListener).
They weigh what a camera writes. A few thousand photos is tens of gigabytes, mirrored offsite rather than archived (see backup) — on a media-heavy site, leaving the box unchecked keeps the originals off the server entirely.
The batch's other box stamps the site's signature into the photos — asked for at upload time or not at all, the signature being burnt into the pixels of every size generated, not laid over them at display time. It costs nothing at render, and it survives a right-click save, which is the point.
The signature itself is not this bundle's: UiBundle stamps it (Service\ImageWatermarker), from two
images uploaded in Site graphics — one for light corners, one for dark. The corner about to be covered is
sampled and the readable one of the two is picked, per photo; a site that uploaded only one gets that one
everywhere. No signature uploaded, nothing stamped, box checked or not.
Three settings drive it, in Configuration, group General:
| Setting | Slug | Default | What it does |
|---|---|---|---|
| Watermark - Corner | ui-watermark-position |
bottom-right |
top-left, top-right, bottom-right or bottom-left — anything else falls back to the bottom right |
| Watermark - Width (%) | ui-watermark-width |
13.75 |
The signature's width, as a percentage of the photo's own (13.75 gives a 330px signature on a 2400px photo) |
| Watermark - Margin (%) | ui-watermark-margin |
0.42 |
Its distance to the edges, same percentage — 0 lays it flush against them |
The batch's Watermark corner field overrides the first of the three for that batch alone, and is left empty by default, which takes the site's corner. It is there for the gallery whose photos all leave the same corner busy. The two others are site-wide only: the signature is measured on the source photo, so a media's whole set carries one signature at one size.
Nothing about the watermark is stored on the media. The question belongs to the file being uploaded, not to the media holding it, so a media's edit screen asks it again — unchecked by default — and only answers for a new file sent from that screen. A file already stored carries the signature it was given, and stamping it again would lay a second one over the first.
A media's title is its name and its alt text. It is not what its slug is built from: the two are
posed together when the media is created and go their own way afterwards.
That split is the point. A title uploaded in bulk is a placeholder — Cailloux couleur 3, or whatever the
camera called the file — and it is retouched precisely because it was one. When the url followed the
title, every such correction moved a public url and cost a redirect, which made naming a batch right the
first time a problem it never had to be. Now retitling moves nothing: the medias worth describing are
described afterwards, one by one, for free.
The slug is posed once and never recomputed. What moves it is an admin editing the slug field itself —
which sits behind EasyAdmin's own padlock, like a category's and a page's, asks for confirmation before it
unlocks, and writes a permanent redirect through GalleryMediaCrudController::updateEntity(). Moving a media to another category moves its url just as
much, the category's slug being the segment above it, and is redirected the same way. What is typed there
is still normalized (Col du Galibier ! is stored col-du-galibier) and still has to be free within the
category — a collision is suffixed (-2, -3) rather than refused, unlike a category's slug, which is the
natural key an import matches on. Emptying the field is how a slug is asked to be rebuilt from the
title, which is the one remaining way to regenerate one.
The stored file keeps the name it was given on upload. It is named after the slug the media had then
(medias/gallery/{category}/{media}-{uniqid}.webp, see GalleryMedia::getVichMediaPath() and UiBundle's
UiMediaNamer), so a file and the page pointing at it read the same — but a later rename does not move it.
Renaming would mean moving three files (medium, thumbnail, high resolution) and costing the old urls their
place in an image index, for a signal the alt text already carries. Re-uploading the file names it after
the current slug.
A visitor browses one resolution only, the stored (medium) file: the index, the grids and the media page all serve it, and the previous/next arrows move from one to the next without ever loading a heavier file. The high resolution has no page of its own — it opens in a lightbox over the image, and is only fetched the first time the visitor asks for it, so a run through a category costs what its medium files cost.
The lightbox is a native <dialog> (assets/js/gallery-lightbox.js): its backdrop, its escape key and
its focus trap are the browser's own, no library involved. It closes on a click anywhere inside it as
well. What opens it is a real link pointing at the high-resolution file, which the controller intercepts:
without javascript the file is still reachable, and the zoom is keyboard-operable for free.
The right click and the drag are blocked on the grids and on the media page
(assets/js/gallery-media-protect.js), with the touch long-press neutralized in CSS. This is a
deterrent, not a protection: the file sits in the browser cache and its url is one developer-tools
panel away. What actually protects a photographer's work here is the medium/high resolution split above —
what is served, not what is forbidden. An app that would rather not block anything overrides the two
grid components and gallery/media.html.twig, dropping the data-controller and data-action
attributes; nothing else reads them.
Signed in with the Site editor role (site-role-editor, ConfigBundle), an Edit button appears
when the pointer or the keyboard focus reaches what it edits, and opens the back office in a new tab —
the same hover button UiBundle draws over an editable block, controller and stylesheet included
(blockEditOverlay), so nothing new is loaded on a page that already carries UiBundle's assets.
| Page | Hovering | Opens |
|---|---|---|
/{prefix}/{category} |
the grid | the category's edit screen — the gallery itself: its heading, its medias, their order and its cover |
/{prefix}/{category}/{slug} |
the media | that media's own form |
A category page carries one button for the whole gallery, not one per thumbnail: a media is edited from its own page, one click further. The heading blocks above the grid keep the button UiBundle already draws for each of them.
The urls are generated, never written out: Twig\Extension\GalleryEditUrlExtension exposes
gallery_category_edit_url(category) and gallery_media_edit_url(media), which ask EasyAdmin where the
CRUDs are mounted; the media one carries the category along, so saving, deleting or cancelling comes back
to the category the media belongs to. The role is checked in gallery/category.html.twig and
gallery/media.html.twig, where it costs no query, and a visitor is served the exact same pages without
the attributes.
The whole gallery back-office sits behind that same role — the categories, a media's form, the upload
screen and the batch actions. A site wanting it reserved to its administrators sets the setting to
ROLE_ADMIN.
The thumbnail file always holds the whole photo, GalleryMedia::THUMBNAIL_SIZE (600px) capping its
longest side — it is only square for a square photo. What the two grids, the categories' and the medias',
do with it inside their square tiles is the Thumbnails showing the whole photo setting
(gallery-thumbnail-whole, group Gallery in Configuration):
| Setting | Rendering |
|---|---|
| off (default) | object-fit: cover — the tile is filled, the edges of a photo that is not square are cut off the display |
| on | object-fit: contain — the whole photo fits in the tile, with bands around it |
Nothing is served differently and nothing is regenerated: the switch adds one class, so it applies on the
very next request and is reversible at any time. The square itself never moves (--gallery-thumb-size),
and the bands take --gallery-thumb-background, transparent by default so the page's own background shows.
600px rather than the tile's own measure because the cropped display only keeps the shortest side of the file — 400px on a 3:2 photo, which still fills a 150px tile on a 2x screen.
A gallery filled before this — its thumbnails cropped square on disk — is brought over with:
php bin/console c975l:gallery:rebuild-thumbnailsIt rewrites every -thumb.webp from the highres derivative each media already carries (falling back on the
stored file when a gallery was imported without them), touches neither the database nor any other file, and
names the medias it found nothing to rebuild from. --dry-run lists what it would write.
On top of the generic block system provided by UiBundle, GalleryBundle registers the following blocks (see config/services.yaml), so a gallery can be placed on any page composed in the back office - a home page's "our latest photos" section, say - instead of only living under its own routes:
| Kind | Category | Description |
|---|---|---|
gallery_categories |
label.category_gallery |
Every category, one thumbnail each, as on /gallery. Takes an optional maximum. |
gallery_medias |
label.category_gallery |
One category's photos and videos, as on /gallery/{category}. Takes the category, an optional maximum, whether to draw them at random, and whether to show a link to the full category. |
Both are cacheable: false: they resolve their content live through gallery_block_*() (Twig\Extension\GalleryBlockExtension), so a block never goes stale against the media library - what a Block stores is what to show (a category slug, a maximum), never the medias themselves. The slug is stored rather than the id, this bundle's natural key everywhere else, so a block survives an export/import to another site the same way a category does; a block pointing at a category deleted or renamed since renders nothing at all rather than an empty grid.
Being uncached is also what makes the random draw worth having: with "draw them at random" ticked, the maximum keeps that many medias out of the whole category, drawn again at every render - so a "our latest photos" section placed on a home page shows a different selection at each visit.
GalleryCategory::$description is the category's own lead-in: rich text typed in its EasyAdmin form
(UiBundle's Trix editor, so Donovan's rephrase button sits under it like under any other rich-text
field of the ecosystem), printed above the grid by gallery/category.html.twig and, stripped of its
markup, reused as the page's description / og:description metas — exactly what SiteBundle's
Page::$summarySocialNetwork does for a page.
One field for both on purpose: what introduces a gallery to a reader is what introduces it to a search
engine, and an admin made to type the same sentence twice would leave one of the two stale. The metas
themselves are written by SiteBundle's layout, which the template feeds through the summarySocialNetwork
Twig variable it reads (og:description truncated to 150 characters there) — an app running on UiBundle's
minimal layout instead simply ignores it, and the description still prints on the page.
It travels with its category through the export/import, an archive predating it importing as a category without one.
It is centered by default, under a short rule parting it from the breadcrumb — aligned with the breadcrumb
above it and the grid below, which is how a category page reads, and sized for the one to three lines a
gallery is actually introduced in. A site describing its categories in several paragraphs sets
--gallery-category-description-text-align: left (centered running text stops reading well past a few
lines), and one wanting no rule sets --gallery-category-description-rule-height: 0 — see
theme for the whole --gallery-category-description-* set.
GalleryCategory implements UiBundle's HasBlocksInterface, so a category carries its own blocks, rendered above its grid by gallery/category.html.twig:
<twig:c975LUi:Blocks:Blocks blocks="{{ category.blocks }}"/>They are edited in the category's own EasyAdmin form, with the full block picker (hero, text_section, image, slider…) - which is how a category introduces its medias ("Reportage Nordkapp, août 2025") without a template of its own. A category with no block renders exactly as before. Management\GalleryBlockOwnerResolver lets a saved block be dragged from one owner to another, nothing to register.
The bundle ships its compiled stylesheet (bundles/c975lgallery/css/styles.min.css, contributed to
UiBundle's stylesheet registry by Service\StylesheetProvider) and, like every other c975L bundle, one
theme file of its own — assets/styles/themes/gallery.css, copied into the app by
php bin/console c975l:scaffold:install and owned by it from then on. Every token ships commented out
at the bundle's default: uncomment a line to take it over, leave it and it keeps following the bundle.
Fonts are deliberately absent from that file, and the site's own colors too: they are admin-editable, in
the theme config group, and the gallery reads them through UiBundle's own --text / --white /
--black / --background / --font-family-body, so a gallery looks like the site it is installed on
with no CSS to write. What the file offers is the gallery's own shapes — thumbnail size and grid gap, the
measure of the media page, the width of the passe-partout, the arrows, the lightbox, the video badge, the
category description and one aspect ratio per declared platform (plus the default an undeclared one is
framed in, and the width a portrait player is capped at).
Hovering a thumbnail bounces it, with UiBundle's own bounceHorizontal — reused rather than redefined,
its animations.min.css being served on every page. --gallery-thumb-hover-animation holds the whole
shorthand: set it to another of UiBundle's keyframes, or to none to leave the grid still. A visitor
asking for reduced motion gets no bounce whatever the token says.
The gallery's own colors are admin-editable too, ten entries in this bundle's own gallery config
group, so a design is retuned from the back office rather than from a file: passe-partout, arrows (color,
hover color, background), lightbox backdrop and close button (color, background), breadcrumb, and video
badge (background, color). What makes them CSS values is their theme-color-gallery-* slug, not the
group they show in: UiBundle's ThemeVariablesCssListener compiles every theme- slug it finds into
--c975l-color-gallery-*, which each token reads with the bundle's own default as its fallback — left
empty, nothing changes. The cascade is unchanged — bundle stylesheet, then the admin's values, then the
app's themes/gallery.css, so uncommenting a color there takes it back from the back office.
A GalleryMedia becomes a video by carrying the url of the page the video is watched on — the one an
admin copies out of their browser's address bar, nothing to extract by hand. Whatever it carries, an
entry always has its own uploaded still: it is what the grids show, so one category holds photos and
videos alike, and nothing is fetched from a third party while a page renders. The url only decides what
opening the entry shows — the still and its lightbox, or the player.
Which platforms is UiBundle's question, not this bundle's: c975L\UiBundle\Video\VideoPlatform is
where one is declared, and declaring it there is all it takes for a gallery to hold it. YouTube, TikTok,
Vimeo and Dailymotion ship declared. What gets stored is always that platform's own privacy-first embed
url, resolved once when the media is saved: youtube-nocookie.com for YouTube, dnt=1 for Vimeo — so
nothing downstream has to remember to ask for it, and a stored url is never the tracking one.
A url belonging to no declared platform is not refused: it is stored exactly as pasted, typed embed,
and framed in the default 16/9 shape. A PeerTube instance of one's own, a player from a platform this
ecosystem never heard of — the admin vouched for the url, and a gallery is not the place to argue. What
is deliberately absent is a "paste your embed code" field: third-party HTML in the database is an XSS and
a CSP hole, where an url is a value nothing executes.
- The type is derived from the url (
image, a platform's name, orembed), never set beside it, so the two can't be left contradicting each other. Emptying the url turns the media back into a still. - A video carries no lightbox at all: there is no high resolution to open, and blowing up the still would be worse than not offering it. Its page shows the player, the breadcrumb naming it as a video.
- The bulk upload screen only ever creates images: an entry becomes a video by editing it afterwards and giving it an url.
A video of the site's own. Next to the url, a media takes an uploaded video file (mp4, webm or ogg), played by the browser itself with the still the entry already carries as its poster — no third party, nothing to consent to, no CSP origin to allow, and a video that outlives whatever a platform decides. What it costs is the storage and the bandwidth, which is why it stands next to the embeds rather than replacing them.
A media carrying both plays its own copy: the file that outlives the platform is the one to play, and
the url stays there to fall back on if the file is ever removed. The ceiling is php's own
upload_max_filesize, not this bundle's 20 MiB one — that ceiling exists to keep a batch of photographs
from taking a shared host down, and would refuse any video worth uploading.
Consent. A player is a third-party frame whatever the platform, so it renders through UiBundle's own
<twig:c975LUi:Video:Iframe> — the iframe is created client-side, and only once the visitor has accepted
the site's cookie banner. On a site carrying no banner the player renders straight away, that component
never blocking content on a site that doesn't ask. There is no per-gallery opt-out: one policy for every
embed the ecosystem serves.
Content-Security-Policy is still the site's own to set, but no longer its own to keep in step — UiBundle exposes every declared platform's origin as a parameter:
# config/packages/nelmio_security.yaml
nelmio_security:
csp:
enforce:
frame-src: ['self', '%c975l_ui.video.embed_origins%']
# The level 1 fallback, for browsers that don't know frame-src
child-src: ['self', '%c975l_ui.video.embed_origins%']A Permissions-Policy header restricting fullscreen has to name those origins as well, or the player's
fullscreen button does nothing. A directive missing is what an empty frame in production and none in
development means. A platform declared under embed is the one case the parameter can't cover — its
origin is whatever the admin pasted, and has to be added by hand.
Under a category's edit form, each media carries a checkbox and the list a toolbar holding the
Add medias button, a "Select all" box and a Delete selection button, disabled until something is
checked (a category with no media yet shows the Add medias button on its own, and says so). The
Add medias button sits there rather than in the edit toolbar above, where EasyAdmin's own "Add a
block" action was the one under the hand of an admin meaning to add a media. The deletion is confirmed through
EasyAdmin's own modal (the one its delete actions open) and posted to
GalleryCategoryCrudController::deleteMedias(), which only ever touches the medias of the category the
url carries, whatever ids reach it. The files go with the rows, derivatives included
(GalleryMediaDerivativeCleanupListener), and a category whose cover was among them loses that cover.
The same toolbar carries a credits box with an Apply credits button, and a "Rights reserved" checkbox
with an Apply rights reserved one — both disabled until something is checked, like the deletion. Each
writes its own field on every checked media, so setting the credits never touches the rights and the other
way round, and both post to GalleryCategoryCrudController::editMedias(), which only applies the field the
button pressed names (a submit button posts its own name/value alone, the other button's control travelling
with it unread). The value is applied as the toolbar shows it: an empty credits box clears the credits, an
unchecked box takes the rights back off — which is the only way to blank either on a whole selection.
In that same grid a tile is dragged to move it among the others, and carries a Cover radio — a
Random cover one sitting in the toolbar above. Both save themselves the moment they are used
(gallery-media-sort.js posting to GalleryCategoryCrudController::saveMediasLayout(), csrf token in the
X-CSRF-Token header, as UiBundle's own block move does): there is no button, the grid not being part of
the edit form above (an html form never nests in another), and nothing on the screen could have told an
admin that its Save button ignores the grid. A call that fails says so and reloads the screen, which
then shows what was actually saved.
The positions are renumbered from 0 following the order posted, so a gap left by a deleted media closes on its own — a media's own edit screen still shows its position as a number, and an upload adds its files after the last one. A category with no cover picked is represented by one of its medias drawn at random on each render, which is the fallback the public index and the admin's thumbnail column have always used and what the Random cover radio goes back to.
The drag itself is UiBundle's gesture layer (addSortGesture(), from its pointer-sort.js), the same one
its ea-sortable uses for a blocks collection: Pointer Events, so a finger and a stylus reorder as a mouse
does. Only where a dragged tile lands is computed here — a wrapping grid of thumbnails has nothing in
common with the vertical list of rows a blocks collection is.
Each tile carries a move handle, and that handle is the grab point at the finger: arming the whole tile
for touch takes touch-action: none over it, and a screenful of thumbnails would leave nowhere to scroll
the page from. With a mouse the whole tile is grabbable, its own clicks surviving — the thumbnail still
opens the media, the two boxes still tick, only a real drag gesture hijacking them.
That import needs its importmap entry in the consuming app, which c975l:config:check-importmap reports:
'@c975l/ui-bundle/pointer-sort.js' => ['path' => './vendor/c975l/core-bundle/UiBundle/assets/js/pointer-sort.js'],Selected categories can be exported as a zip (title/slug/blocks/medias, files bundled in) via the category
index's "Export selection" batch action, meant to be re-uploaded on another site/environment through
ConfigBundle's Import content dashboard screen (see GalleryImportProvider). Ids never need to
match between the two sites: a category is matched by slug on import, the slug being unique (a second
category taking a slug already used is refused by the form). GalleryExportProvider (the same serialization, every
category) also plugs categories into ConfigBundle's Export sync (everything) dashboard shortcut.
A category's heading blocks travel with it, their own medias joining the
archive, and are replaced wholesale on import — the same way PageImportProvider replaces a page's. An
archive exported before categories gained a heading imports as a category without one.
A media that kept its original carries it into the archive too, put back under
private/ on import, so an imported gallery can still be re-processed without a re-upload. Nothing travels
about the watermark, there being nothing stored to travel: the archived file
already carries the signature in its pixels, and the import asks for none, which would lay a second one
over the first.
The urls are declared by GallerySitemapProvider (ConfigBundle's SitemapProviderInterface): the /gallery
index, one entry per category, and one per media — a media has a page of its own, which is what an image
search actually lands on. GalleryCategory carries no date of its own, so a category page is dated by its
most recently touched media. Nothing to register — the provider is picked up automatically.
The index and the categories also carry a title, which the sitemap ignores and which ConfigBundle's
SeoFilesWriter builds the site's public/llms.txt from. The medias deliberately carry none, and an
untitled url is skipped there: a gallery declares one url per media, and listing them all would turn
llms.txt into a Markdown sitemap.
public/sitemap-gallery.xml and the site's public/sitemap-index.xml are written by ConfigBundle, which
collects every installed bundle's provider:
php bin/console c975l:sitemaps:createThose same urls are also health-checked for free, ConfigBundle's DeclaredUrlsHealthCheckPass registering
one check per declared sitemap with nothing to implement bundle-side: every declared url
gets the content-quality checks (title/description length, missing <h1>, Open Graph share tags, images
without alt, broken links) under its own urls-gallery kind on the Health check dashboard. Worth keeping on
its own, less frequent schedule — a gallery declares one url per media:
php bin/console c975l:health-check:run --kind=urls-galleryConfigBundle backs up nothing it wasn't declared, so GalleryBackupPathProvider names this bundle's two
upload roots — the only content of a gallery that neither a git clone nor a database dump brings back.
Nothing to register, the provider is picked up automatically:
| Path | Mode |
|---|---|
public/medias/gallery |
mirror |
private/medias/gallery |
mirror |
mirror rather than archive: they are copied as-is by c975l:config:backup:offsite, never tarred and
never dated — a photo needs a copy, not a version history, and bzip2 gains about nothing on a webp. The
derivatives, the self-hosted videos and the kept originals all live under those two roots, so nothing else
is declared. A site with no gallery yet declares two folders that aren't on disk, which are skipped
without an error.
php bin/console c975l:config:backup:offsite # mirrors the declared folders, this bundle's two includedconfig/whatsnew.json holds this bundle's own news, WhatsNewProvider (ConfigBundle's
WhatsNewProviderInterface) handing it over. ConfigBundle merges every installed bundle's entries by date
and shows the latest of them on the dashboard, the whole history being a click away. Nothing to register —
the provider is picked up automatically.
One row per date, in reverse chronological order, each description translated in the three locales the bundle covers; the visitor's own locale applies, English being the fallback:
[
{
"date": "2026-08-08",
"description": [
{
"en": "A category can carry a description…",
"fr": "Une catégorie peut porter une description…",
"es": "Una categoría puede llevar una descripción…"
}
]
}
]Written for the site's owner rather than for a developer: what changed on the screens and on the public pages, not which class carries it — the ChangeLog is where the code's history lives.
A site arriving with its photos in a folder tree — served by a hand-rolled Symfony\Finder listing, by
another gallery bundle, by anything — brings them in through the back office, one category at a time:
- create the category — Gallery in the menu, then Add,
- on that category's own row, click Add media and select the whole folder at once in the file picker — the field takes as many files as you give it, credits and rights-reserved applying to the batch and retouchable one at a time afterwards,
- repeat per folder.
Each media's title is seeded from the name of the file it came in as, underscores and dashes read as
spaces: mont-blanc-2019.jpg lands as Mont Blanc 2019. That title is the media page's own heading, its
alt text, its url and the name its stored file is given, and the uploaded name is not kept anywhere
afterwards — so rename the files before uploading them if they are numbered (114.jpg gives a title of
114, a url of /114 and a file called 114-*.webp), and retouch what matters one at a time from the
category's edit screen, which lists its medias. Retitling one afterwards moves its url and leaves a
redirect behind, but does not rename its stored file (see renaming a media).
Upload the originals, not the derivatives an older gallery generated alongside them (-small,
-thumb, a thumbs/ subfolder…): this bundle derives its own thumbnail, medium and highres from what it
receives, and feeding it an already-shrunk file caps the quality of all three for good — a source
narrower than 1024px leaves the high resolution with nothing to show over the medium one.
A bulk upload meets four of them, and the first three are PHP's own. PHP does not refuse a batch that
exceeds them — it truncates it: past max_file_uploads the extra files are dropped without a word,
past post_max_size the request arrives empty, csrf token included. Neither can be recovered from once
the request has landed, so both screens that carry a batch — the upload screen and the category creation
form — check the selection before sending it:
| Setting | Common default | What it caps |
|---|---|---|
max_file_uploads |
20 | Number of files in one submission |
upload_max_filesize |
2M | Each file |
post_max_size |
8M | The whole batch |
UploadLimits::MAX_FILES |
100, this bundle | Number of files, whichever of the two is smaller applying |
UploadLimits::MAX_FILE_SIZE |
20 MB, this bundle | Each file, same rule |
The bundle's own two are there because a host being generous says nothing about what the batch costs once it lands: every file is decoded, resized three times and written back inside that one request. A category of 150 files is two uploads, which it takes just as well — positions simply continue where the first batch left off.
Service\UploadLimits reads the three settings from the running PHP, so the screen states the ceilings
that really apply — in the field's help before anything is picked, and again in
assets/js/gallery-upload-limits.js, which weighs the selection the moment it is made and names what is
wrong (how many files over, which ones are too heavy, what the batch weighs) — the upload screen also
disables its submit button, the creation form leaving EasyAdmin's own buttons alone. A batch that gets
past the check anyway is caught server-side on both screens and reported rather than silently
redisplayed. Nothing to wire up: the controller ships as this bundle's EasyAdmin entrypoint
(assets/controllers-admin.js, contributed through Service\ScriptProvider and Management\ImportmapProvider).
Raise the three in the site's own php.ini if they sit below what the bundle allows —
max_file_uploads = 100, upload_max_filesize = 20M, post_max_size = 300M lets a full batch through.
Note that max_file_uploads is PHP_INI_SYSTEM: a .user.ini cannot raise it, only the server's own
configuration can. Both constants on UploadLimits are there to be raised by an app that knows its
server takes more.
This bundle deliberately ships no import command for that. What such a tool would have to guess — which files are originals and which are an old gallery's derivatives — has no answer that holds from one site to the next, and getting it wrong imports blurry duplicates that then have to be found and deleted by hand. Photos already managed by this bundle on another c975L site are a different matter: they move across with export / import categories, files and all.
Tip
If this project helps you save development time:
- star it on GitHub — helps others find it
- open an issue to share how you use it — genuinely useful feedback
And if you'd like to support the work directly, the Sponsor button at the top of the GitHub page is there for that. Thank you!