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.
See it in action at bundles.975l.com/pages/gallery-bundle, and browse every block kind live in the block gallery.
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 · the automatic gallery · renaming a category · deleting a gallery · uploading a batch · renaming a media · a media's caption · fields of your own · browsing and the lightbox · editing from the public pages · blocks · category summary · share image · category headings · theme tokens · videos · trashing a selection · credits / rights on a selection · downloading a selection · export / import categories · sitemap and health check · describing the gallery index · backup · what's new · guided projects
- Operating — likes on a photo · bringing an existing gallery in · upload ceilings · AI agent skills
- A heart under a photo, behind one setting: visitors like it without an account, and the page says how many did (see likes on a photo).
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). The screen counts the megabytes as they leave and then says the files are being processed, a batch being minutes of waiting.
- 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 goes to the trash in one go instead of one edit screen at a time (see trashing a selection), or given the same credits and rights at once (see credits / rights on a selection), or their files handed back as one zip (see downloading 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.
- One category of the site can be turned into the gallery of the last additions: it holds no media of its own and shows what every other category received on its last days of upload, whatever gallery each photo landed in - as a public page, as a block, and as a back-office screen where a whole upload session is credited, downloaded or trashed in one go (see the automatic gallery).
- 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 summary, printed above its grid and reused as the page's social/search metas (see summary).
- Every gallery page hands one of its own photos to a social network as its
og:image, rather than the site's logo — a shared gallery shows what it holds (see share image). - 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 listed in the "Descriptions d'urls" screen, via ConfigBundle's
UrlMetadataProviderInterface, ready to be described without anyone typing its path (see describing the gallery index) - 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) - Six replayable guided projects contributed to the dashboard, via ConfigBundle's
GuidedProjectProviderInterface, walking a gallery's creation, its medias' arrangement, a media's own screen, the trash and the way back out of it, the files handed back as an archive, and the gallery of the latest additions (see guided projects) - A skill written for the coding agents of the sites installing this bundle, shipped in the package and read straight from
vendor/(see AI agent skills)
- PHP >= 8.4
- Symfony ^8.0
- c975L/CoreBundle in
^1.14.0— ConfigBundle and UiBundle ship as the singlec975l/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).^1.14.0is what reads the role a menu entry states, without which the gallery's own sidebar entry falls back on the admin bar and an editor never sees it - 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.
The gallery of the last additions is a gallery of its own, and nobody creates it. A photo library is
arranged by subject - Animaux, Arbres, Fleurs - so an upload session is dispatched across a dozen
categories at once, and nothing on the site says a single photo has arrived. Derniers ajouts is the one
screen that shows them all: GalleryCategoryRepository::findOrCreateAutomatic() writes it the first time
the galleries are listed - the back-office listing, the public index or a categories block, whichever comes
first - and it is a normal category from then on, flagged GalleryCategory::$automatic. Rename it,
describe it, give it a heading, move it up or down the index: it takes everything a gallery takes. It is
never an option carried by one of your own galleries, which ticking a box on Animaux would turn into
something it isn't.
What it shows is a rolling window of calendar days, today included
(GalleryMediaRepository::findLatest(), driven by Service\GalleryLatestProvider), settled by the two
configuration entries of the Galerie group:
| Entry | Default | What it decides |
|---|---|---|
gallery-latest-days |
7 |
How many days back the gallery reaches, today counting as one of them |
gallery-latest-max |
200 |
The ceiling. Past it, only the most recent medias are shown |
It is never empty as long as the site holds a photo: a window that catches nothing - nothing published for a week - falls back on the last day that does carry an addition, and on that day alone. Without it, a site publishing once a month would show an empty gallery three weeks out of four, and its tile would simply vanish from the index, having no photo left to draw itself with.
The ceiling is what keeps the day a whole library came in at once, a migration or an import, from being
served whole: it also bounds each query the gallery costs, which the index on gallery_media.created_at
answers. Past it the most recent medias are the ones kept, the list being ordered by date of addition.
On the public site it is a gallery like any other: same route, same template, same grid, its own
summary, its own heading blocks, its own tile on the index (showing its
newest photo rather than one at random), a menu target and a line in the
sitemap. Its thumbnails link each photo under its own category - /{prefix}/objets/objet-1, never a
second path to the same photo - with ?from=<its slug> over it, which is what says the visitor is walking
the last additions rather than the gallery filing that photo. The media page reads it: previous and next
are then the medias added just before and just after, whatever category they sit in, and the breadcrumb
leads back to the last additions. A photo that has since left the window is browsed as its own category's
again, and ConfigBundle's canonical_url() drops the query string, so the canonical url stays the media's
own. Its slug is picked like any other category's, the shipped one being latest. Pointing a
Galerie - médias block at it puts the site's last additions on the
home page.
In the back office its edit screen is the cross-gallery selection screen: the same grid, cut into one section per day of additions, each thumbnail naming the gallery the photo actually belongs to and opening its edit form. The selection toolbar is the one every category carries - credits and rights, downloads, move to trash - and each of them acts on the photo where it really sits: trashing one from here takes it out of its own gallery, cover included. What is left out is what belongs to the owning category alone: no upload button, no drag to reorder, no cover radio, no trash view of its own.
Moving it to the trash is how a site says it doesn't want it, the same button every other gallery
carries: findOrCreateAutomatic() leaves a trashed one exactly where it was put, where the catch-all
"Non classé" is lifted back out. Restore it and it is back, with the additions of the moment. An imported
archive carrying the flag only takes it on a site that holds no such gallery at all, trash included, so an
import never leaves two of them behind.
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.
Deleting takes two deliberate steps, and the first one loses nothing. A category is moved to the
trash from its own edit screen — at the foot of it, deliberately (see
where that button sits) — as well as from the listing's row
button, EasyAdmin's own confirmation modal standing in the way either way. What that writes is a flag and nothing else
(GalleryCategory carries CoreBundle's TrashableTrait): the category leaves the site, its medias, its
heading blocks and every one of their files stay exactly where they are — the cascade on
GalleryCategory::$medias and Listener\GalleryMediaDerivativeCleanupListener are simply never reached.
The listing's Corbeille button switches it to what it holds, and back. There, a category carries two
actions of its own: Restaurer, which puts it back untouched, and Supprimer définitivement, which
is the one that removes the row, its medias, its heading blocks, their three derivatives and any kept
original — and the directory the category grouped its files under, in public/ and in private/, once
it is actually empty. That one is held at site-role-admin, the rest of the gallery sitting at
site-role-editor: it is the only irreversible action of the screen.
Exporting still works from the trash, deliberately: a category can be carried to another site, or kept aside as an archive with all of its files, before it is dropped for good (see Export / import categories).
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). Flagged as trashed some other
way — a fixture, an import — GalleryCategoryRepository::findOrCreateUncategorized() lifts it back out.
A trashed category or media answers 410 Gone, not 404, and answers it from the row itself
(GalleryController::resolveCategory(), the same shape SiteBundle serves a trashed Page with). That
410 lasts exactly as long as the entity can still be restored.
The permanent deletion is what declares the url gone for good. Every category page and every media
page is declared in the sitemap (Sitemap\GallerySitemapProvider), and a 410 is what drops a url from an
index, where a 404 is retried for months. Service\GalleryUrlRedirector writes the rows in ConfigBundle's
Redirections: one for the deleted url, plus a single wildcarded one (/{prefix}/{slug}/*) covering
every media of a deleted category rather than a row per media. The rows that redirected to that url answer
the same 410 directly, so nothing points at a page that is gone. Nothing is written at the move to trash:
a url that can still come back must not be declared gone.
Restoring a category releases any such row left under its url by an earlier permanent deletion, exactly as
creating a category or uploading a media under a freed slug does (GalleryUrlRedirector::release()) — the
redirect is resolved before the router, so the page would otherwise exist while its url kept saying it
doesn't. A row that redirects somewhere is never touched.
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.
Fifty photos are minutes of waiting: the transfer itself, then the resizing, the conversion and the
watermarking of every one of them, all inside that one request. The upload screen therefore sends the
form over XMLHttpRequest and states both phases — a progress bar counting the megabytes as they leave,
then an indeterminate one saying the files are being processed — with the submit button taken away for
the whole wait, a second click being what an idle-looking screen invites. That bar is UiBundle's
upload-progress controller, armed by UploadProgress::formAttr() on this bundle's form; the controller
hands the arrival url back to it instead of redirecting, so the "medias added" flash is shown on the
screen that follows rather than spent on a page nobody sees. Nothing to wire up, and a browser running no
JS at all still posts the form the plain way.
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. A media left without a title takes
its category's as its alt — on its thumbnail in the grid as in the lightbox — a photo being announced
by the gallery it belongs to rather than by its url.
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.
Under the title sits a description: the caption read under the photograph on its own page, free text and as long as it needs to be. Nothing composes it and nothing fills it in — a media without one renders no caption at all, which is what most of a batch is. It is hidden from the grid of the back office, where a paragraph per row would bury the thumbnails that list exists to show.
Where the title names the media in a grid and doubles as its alt text, the caption says what there is
to say about it: where it was taken, who is in it, what the visitor is looking at. That is also why it is
the page's own meta description and og:description when there is one — nobody summarises a photograph
better than whoever filed it, and the composed sentence (see the image a shared page
carries) is only the fallback for a media carrying no caption.
It travels with its media through the export/import, an archive predating it importing medias without one.
Three theme tokens size and color it (--gallery-media-description-font-size, -line-height, -color) —
full text color and full size on purpose, it is read where the credits under it are only glanced at. Four
more frame it as a card under the media (--gallery-media-description-padding, -background,
-radius, -shadow), taking the site's own surface, radius and shadow rather than a look of its own.
A gallery and a media each carry a data payload — one nullable JSON column, not a column per field —
holding whatever a single site records that no other one does. The same move Block::$data and
Book::$data make, and for the same reason: what one site needs never costs a schema migration to every
app running this bundle.
A site declares them by implementing GalleryCustomizationProviderInterface — two methods, each returning
a plain form type or null:
class GalleryCustomizationProvider implements GalleryCustomizationProviderInterface
{
public function getCategoryDataFormType(): ?string
{
return null;
}
public function getMediaDataFormType(): ?string
{
return MediaDataType::class;
}
}Nothing to tag and nothing to declare in services.yaml: the provider is collected on sight of the
interface (gallery.customization_provider). The form is rendered as one field on the edit screen, under
the bundle's own, and a site declaring nothing gets no field at all — which is why this costs an app
that wants none exactly nothing. The payload travels through the export/import whole, without the archive
knowing its shape.
Read it back with getDataValue(), so a template never spells the payload out:
{{ media.getDataValue('photographer') }}What does not belong there: anything the database has to filter, sort or join on, and anything every
gallery wants. A caption is the second case — hence description, a column of its own.
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 breadcrumb opening every page says how much each level holds — the number of categories beside the
gallery's own label, the number of medias beside a category's title — so a visitor reads the size of what
they are stepping into before stepping in. The index counts the list it has already read, a category and a
media page count without listing. A caller passing no count gets the bare label rather than a (0).
The previous/next arrows are revealed by the pointer, sitting on the photo, which is what the page is for: they fade in when the pointer enters the media, and a keyboard focus reveals them just as well. On a touch screen, where a first tap would be spent making them appear, they simply stay on.
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, which is why it carries no close button: a cross in the corner would only cover a part of the very
image it was opened to show. 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 trip back is a View on site action, on the categories list and on a category's own screen, opening the gallery in a new tab — the same action a page carries in SiteBundle. There is no preview twin to it: a category has nothing to publish, it is online the moment it exists.
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::$summarySocialNetwork 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 — named after SiteBundle's
Page::$summarySocialNetwork and ConfigBundle's UrlMetadata::$summarySocialNetwork, which hold the
same text in the same role, so a site meets one name for it rather than one per bundle.
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 the layout, from the summarySocialNetwork Twig variable the template sets
(og:description truncated to 150 characters) — the Trix markup is reduced there, by the plain_text
filter both layouts apply, so the summary is handed over as typed.
It travels with its category through the export/import, an archive predating it importing as a category
without one, and one exported before the rename read under its old description key.
The two pages next to it fill the same variable their own way: a media uses its own caption when an
admin wrote one (see a media's caption), and composes it from the site name, its
category, its own title and its credits when none was — those four being what situates a photo nobody
described. The index of the gallery has no entity behind it at all, so it takes the summary an
admin wrote for its path in ConfigBundle's UrlMetadata, without a line of code here (see
describing the gallery index).
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.
A gallery pasted into a message is a photo, not a logo: each of the three pages sets the ogImage Twig
variable the layout reads, so what a share shows is what the page actually holds.
| Page | Image |
|---|---|
| A media | its own stored (medium) file - a video entry shares its still |
| A category | its cover, or one of its medias at random for lack of one |
| The index | one of the galleries' own faces, taken at random |
GalleryCategory::getCoverOrRandomMedia() is what both the category and the index read, the same face a
category's index tile and its back-office row carry - a category is left with no cover until one is picked
on its edit screen, and would otherwise show nothing at all. The medium file rather than the highres: the
thumbnail is capped at 600px and cropped by the grid, and the highres weighs megabytes for a card nobody
views at 2048px.
The index yields to an admin's own choice — a row written for its url in the Descriptions d'urls screen (see describing the gallery index) carrying an image wins, the random one being picked only because nobody picked one. A category and a media set theirs outright, having no row of their own to read. A page with no photo at all falls back on the site's default og-image, as any other page of the site does.
A social network caches that preview under the page's url and never reads the page again on its own, so a cover picked after a first share only shows up once the page has been re-scraped. The category's edit screen carries ConfigBundle's note about it — the same one the page and Descriptions d'urls screens show — linking Facebook's sharing debugger on the category's own url.
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, the card a caption is read in and one aspect ratio per declared platform (plus the
default an undeclared one is framed in, the width a portrait player is capped at and the height a video of
the site's own is).
A photo shows against a ground of its own, darker than the rest of a site usually wants to be. That is
what the gallery-style config (kind choice) is for: light (near-white page) or dark (near-black
page). Left empty — the shipped default — the gallery takes the site's own colors, exactly as before. The
three pages of the viewer (index, category, media) fill the bodyClass block SiteBundle's layout offers,
and the class they hand it is written by gallery_body_class() (Twig\Extension\GalleryStyleExtension),
which drops a value no block paints rather than putting it in the markup. An app whose layout offers no
bodyClass block simply renders the gallery on the site's background.
What a style retunes is UiBundle's own palette — --background, --text, --black, --white,
--primary, --link-color — and not a --gallery- namespace of its own, which is the one thing that
makes it reach what this bundle does not itself style. SiteBundle writes color: var(--text) through a
* rule, so a color declared on the body never reaches the h1, a composed block or anything else the
page holds, a real declaration always beating an inherited value: that is what left a gallery's title in
the site's color, unreadable, on the gallery's own ground. Retuning the tokens instead leaves every rule
of every bundle where it is and has it resolve to the gallery's values — the page's background, the canvas
beside it, the titles, the links, the cards, all of it, navbar and footer included, the rest of the site
keeping its own. dark retunes two more of SiteBundle's on top of that, both being roles --primary
alone does not settle: --title-color, the headings otherwise reading a deep brand color at barely more
than 1:1 on a near-black page, and --footer-background / --footer-text, a band of brand color cutting
across that same page — with --footer-link-hover-background dropped to transparent, a wash meant to
lift a colored band reading as a lit rectangle on one this dark, and --navbar-site-name-color taken to
the titles' own ink so the header reads as one. None of them is set in light, where against a near-white
ground they read exactly as they do on the site. --primary itself is left alone throughout — it is a
surface elsewhere (the primary button, a primary flat) whose white label needs it dark, so repainting
it would take the buttons with it. The blocks are declared on :root:has(body.gallery-page--…) rather than on the body,
the canvas beside the page being painted on html, above it; their specificity puts them above
SiteBundle's own :root[data-theme="dark"], so a gallery asked for light stays light on a site fixed to
dark.
The gallery-frame config (kind choice) picks the passe-partout a displayed media is framed with —
none, thin (the shipped default) or wide. Its color is not part of the choice, being the theme's own
ink: white on a dark gallery, black on a light one, so it inverts along with the style — a mount the
color of the ground it is laid on being a mount nobody sees. It stays admin-editable on its own.
The same mount frames the high resolution in the lightbox, off those same two tokens: a print stays a
print, opened over the page as laid on it, and the choice made in the back office carries to both without
a token of its own. The image is border-box there, so the mount is taken off the dialog's measure rather
than added to it — added, it would run past a max-height the dialog clips at. Worth knowing on a light
gallery, whose ink is near-black against a lightbox backdrop that is near-black too: the mount is there,
but barely read. A design wanting it seen on both grounds gives the lightbox a color of its own, the
--gallery-media-frame-color token being overridable under the .gallery-lightbox__image selector.
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, nine 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, breadcrumb, credits, 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. Five of them are loaded with that fallback as their own value, so the back
office states the color rather than showing an empty field an admin has to guess at; emptying one paints
the very same color, the fallback being what it was read from. The four others are left empty on purpose:
their fallback is not a fixed color but an expression — the theme's own ink for the passe-partout, the
arrows' color for the hover, a mix of the site's text and background for the breadcrumb and the credits —
so they follow a light or a dark gallery, which a value written in would freeze. ThemeColorDefaultTest
keeps the two lists in step. Those laid over a media rather than on the page — the arrows, the lightbox,
the video badge — default to literal black and white and not to var(--black) / var(--white): those two
are the site's ink and paper, which a dark site swaps, where a chevron on a photo wants the same white on
the same dark button whatever the page around it is. 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 self-hosted video is the one player whose real shape the browser reads off the file itself, so it is
left to dictate it rather than framed in a ratio — and capped by the viewport's height
(--gallery-video-self-hosted-max-height, 70vh), a portrait file otherwise running past the fold on any
screen. The cap is laid on the height and the width follows, which is what a <video> does on its own:
nothing here knows the file's ratio, where a framed player takes its cap as a width
(--gallery-video-portrait-max-width) precisely because its platform's shape is declared. The player is
then shrunk to what it comes out as, so its own border frames it and the previous/next arrows stay
against it instead of stranded in the margin.
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.
Move to trash for the whole category is deliberately not in the page toolbar, where EasyAdmin puts
it by default: it sat one row above the photographs an admin was checking, next to Save, and a click meant
for the media selection took the entire gallery off the site. gallery_category_edit.html.twig overrides
page_actions to leave it out and renders it at the foot of the page instead, under its own heading
and a line saying what the trash keeps — read before it is clicked, which is the whole point. It is
EasyAdmin's own action, only rendered elsewhere: same url, same confirmation modal, same permission. The
row button on the galleries index is untouched.
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 move 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. Nothing is removed: the rows are flagged, their files stay, and a
category whose cover was among them loses that cover rather than keeping one it no longer displays.
A media has a trash of its own, independent of its category's, so a photo can be taken off a gallery
that is perfectly online. The heading above the grid carries the way in, with the count it holds, and the
way back. In the trash the toolbar swaps its arranging, crediting and cover controls — none of which mean
anything there — for Restaurer la sélection and Supprimer définitivement, posting to
restoreMedias() and deleteMediasPermanently(). The second is the only path in this bundle that ever
removes a media, and the only one that reaches its four files
(GalleryMediaDerivativeCleanupListener); like a category's, it is held at site-role-admin.
A category put in the trash marks none of its medias, and restoring it gives back exactly the ones that were showing when it left.
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.
Two more buttons on that toolbar hand the files themselves back, as one zip:
Download high resolution packs each checked media's -highres.webp, Download originals packs the
untouched uploads kept aside at upload time (see uploading a batch). They are the two
files a site holds and no url hands back — the highres exists under public/ but is linked nowhere as a
file, and the original sits outside public/ altogether — so getting a batch of them back used to mean an
ssh session.
The same two buttons sit in the trash view of that screen: a photograph waiting to be dropped is exactly the one whose originals are worth getting back first. They are the one selection action that does not filter on the trash state — reading a file is the same act online or not, where the state is what keeps a selection posted from the grid away from the permanent deletion.
Both post to GalleryCategoryCrudController::downloadMedias(), which reads only the medias of the category
in the url, exactly as the trash does. Inside the archive each file is named after its media's slug
rather than after the stored name, keeping its own extension (mont-blanc.webp, mont-blanc.jpg) — an
original keeps the format it was shot in, where every derivative is a webp. Entries are stored, not
deflated: a webp and a jpeg are already compressed, and the pass would cost a full read of every byte for a
percent.
A media whose file is gone, or whose original was never kept, simply contributes nothing; a selection where
that is true of every media gives no archive at all but a message saying so. The selection is weighed
first, and refused past GalleryMediaArchiver::MAX_TOTAL_BYTES (1 GB) with its own size stated: a whole
gallery of originals is tens of gigabytes, which no browser download should be asked to carry — nothing is
ever handed over truncated. Both actions sit at site-role-editor like the rest of the screen, reading
files and changing nothing.
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.
The trash flag travels too, on the category and on each of its medias: a category exported out of the trash comes back to the trash, not onto the site, and a sync mirrors its source rather than republishing what an admin had taken down (see deleting a gallery). An archive predating the trash imports as what it describes — a category that is not in it.
Every file a media holds travels: the stored one, its thumbnail and high resolution siblings, its
self-hosted video, and the original it kept — put back under private/ on import, so
an imported gallery can still be re-processed without a re-upload. They travel with their names, and are
laid straight back under them: the upload pipeline is skipped entirely, so an imported gallery is the same
gallery down to the bytes, and answers at the very same image urls on every site it is synced to. That
matters for urls that are shared and cached — and it also means importing a category of three hundred photos
copies files instead of resizing three hundred images.
A name coming out of an archive is only honoured under public/medias/gallery/ (and private/ for the
original), as a plain relative name: anything climbing out of it is refused and the file named by Vich
instead, as an archive exported before the names travelled is. Such an archive also has its thumbnail and
high resolution recomputed from the stored file — which is why they travel now: the high resolution came
back at the stored file's own width, and each round-trip re-encoded the webp once more.
Nothing travels about the watermark, there being nothing stored to travel: the archived files already carry the signature in their pixels, and the import asks for none, which would lay a second one over the first. That is also why the derivatives are archived rather than rebuilt from the kept original, which is copied aside before any signature is laid.
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-galleryA second check answers for the files rather than for the pages: Management\GalleryFilesHealthCheckProvider
(kind files-gallery) reports, as an error, every photograph whose stored image — or whose self-hosted video —
is no longer under public/. A gallery is the one place where that goes unnoticed for months: the row is still
there, the grid still lists it, and only the tile that fails to load says anything. Everything the check does is
UiBundle's AbstractDeclaredFilesHealthCheckProvider, this only names the files to look for: the ones the rows
themselves name, never the thumbnail and highres derived from them, which a re-upload rebuilds. The trash is left
out — a media taken off the site is served nowhere.
php bin/console c975l:health-check:run --kind=files-galleryGalleryUrlMetadataProvider (ConfigBundle's UrlMetadataProviderInterface) declares the /gallery path in
the Descriptions d'urls screen, so the row is there to be filled in rather than typed by hand — a path
one slash apart would describe an url that does not exist, and nothing would say so. Nothing to register,
the provider is picked up automatically:
php bin/console c975l:url-metadata:syncThat one page only: a category and a media each say their own from their columns (see a category's summary), and a row written for them would never be read. The configured route prefix is read at sync time, so a site renaming it gets the new url declared and the former one reported as orphaned, to be removed from the back office.
ConfigBundle 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.
GalleryGuidedProjectProvider (ConfigBundle's GuidedProjectProviderInterface) contributes six replayable
exercises to the dashboard's "Guided projects" panel: creating a gallery with its first photographs in
one go — the creation form carries the whole batch, which is the only screen doing both —, arranging a
gallery's medias on its own edit screen, where the order, the cover and the batch edits all save as they
go, filling in a media's own screen, where a caption is written and a video attached, putting a gallery
aside and bringing it back, which walks the trash and stops before the permanent deletion — held one role
higher, so a step highlighting it would point at a button an editor never sees —, getting the photo files
back as one archive, and the gallery of the latest additions, the one gallery arranged by nobody.
Nothing to register — the provider is picked up automatically.
Only the opening step of each carries an url, all six sending the user to the categories, the single
sidebar entry of the whole feature — which states site-role-editor itself, the bar its own screen sits
at, rather than taking the admin default every entry used to be given. From there the panel walks that
screen, highlighting the button or the field they are meant to use next — one they click themselves, which
brings the panel back on that very step:
| Pointed at | What it is |
|---|---|
.action-new, .action-edit, .action-saveAndReturn, .action-delete, .action-trash, .action-restore |
EasyAdmin builds an action-<name> class from the action's own name — saveAndReturn, not save |
#GalleryCategory_title, #GalleryCategory_titleRoot, #GalleryMedia_title, #GalleryMedia_credits, #GalleryMedia_externalUrl |
plain form fields, pointed at through their rendered id |
#GalleryCategory_files |
the batch upload of the creation form |
[data-gallery-upload-medias], [data-gallery-cover-radio], [data-gallery-media-sort-handle], [data-gallery-media-selection-target="toggle"], [data-gallery-download-medias] |
markers carried by this bundle's own templates, the elements having no id of their own |
.management-media-grid, .management-media-grid__item |
the medias grid, and a thumbnail of it opening the media it stands for |
An app overriding templates/management/gallery_category_edit.html.twig keeps those data- attributes, or
the steps resting on them point at nothing — they are read as selectors, not as behaviour.
All six are gated by site-role-editor, the same ConfigBundle entry the gallery's management screens sit
behind: an admin without it is never offered a parcours ending on an access-denied page. Their order (140
to 190) continues the ecosystem's sequence, after ConfigBundle (10-40), SiteBundle (50-80), UiBundle
(90-110) and SocialBundle (120-130). Nothing is derived from the site's own data, so a project is worth
following on a site already full of galleries, and worth replaying once done (see ConfigBundle's README,
"Contributing guided projects from other bundles").
gallery-rating, on out of the box, puts a heart under the media page's photo, next to its credits. It is
UiBundle's rating widget asked for one icon and one only (scale="1" icon="heart"): a photo is liked or it is
not, so there is no average to print — the line under it says how many people liked it, and clicking the heart
again takes the like back. Both are stated by this bundle rather than left to the site's own ui-rating-icon /
ui-rating-scale, which serve the scales elsewhere.
No login is asked for and no cookie banner is owed: an authenticated visitor is keyed on their account, anyone else on a token their own browser mints on the click. See UiBundle's Visitor ratings section for the whole of it.
Removing medias for good drops their likes — from the trash's own selection, from a category deleted with everything under it, and from the medias a reimport replaces alike — never when they are merely trashed, a photo coming back having to find its likes where it left them.
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.
The package ships a skill of its own, skills/c975l-gallery/SKILL.md, written for the coding agent of
the site installing this bundle rather than for someone modifying it. Point your agent at it:
vendor/c975l/gallery-bundle/skills/
It holds what an agent gets wrong when left to its own habits — that the url prefix is admin-editable
and never to be written out, that a setting goes in config/configs.json and not in .env, that the
image derivatives are UiBundle's work and not a resizer to write, that a video platform is declared in
UiBundle — alongside the routes, the entities, the config slugs, the blocks, the Twig functions and the
components, each named as it actually is in the sources.
Nothing is installed, nothing is copied into your project: the file sits in vendor/ like any other
part of the package and follows it at each composer update. A user of Claude Code wanting it to load
by itself symlinks it into their own skills directory:
ln -s ../../vendor/c975l/gallery-bundle/skills/c975l-gallery .claude/skills/c975l-galleryTests\SkillsTest keeps the file honest: every path, route, config slug, command, class member,
Twig function, block kind and component it quotes is checked against the sources, so renaming any of
them fails the build rather than leaving an agent confidently wrong.
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!