Use this skill when working with photo galleries in a Symfony application built on the c975L ecosystem with c975l/gallery-bundle. Covers categories and medias, the admin-renamable url prefix, the two-step trash, batch upload and the three image derivatives, videos and embeds, the two gallery blocks, theming, and every extension point the bundle offers. Triggers on: GalleryCategory, GalleryMedia, gallery-route-prefix, gallery_index, gallery_category, gallery_media, gallery_categories, gallery_medias, c975l:gallery:rebuild-thumbnails, c975l:gallery:fill-slugs, c975l:gallery:automatic:dedupe, photo gallery, thumbnail, lightbox, Image:Zoom, image-zoom__image, batch upload, upload progress, passe-partout, trash, restore, delete permanently, 410 Gone, download highres, download originals, GalleryMediaArchiver, move medias, move selection, GalleryMediaMover, moveMedias, files-gallery, health check, automatic gallery, latest additions, GalleryLatestProvider, findOrCreateAutomatic, findLatest, gallery-latest-days, gallery-latest-max, gallery-rating, likes, like a photo, heart, rating, findVisibleByCategories, setLoadedMedias, media caption, media description, GalleryCustomizationProviderInterface, gallery.customization_provider, GalleryDataField, getDataValue, gallery-video-self-hosted-max-height, self-hosted video, portrait video., GallerySampleCatalog, GalleryDemoFixtureProvider, DemoFixtureProviderInterface, demo gallery, ReplacingFile, DemoFixtureMediaCopier, hidden, hide a gallery, masking, sell prints, print shop, limited edition, editionSize, printable, certificate of authenticity, gallery_print_certificate, gallery_print_file, gallery_print_callback, c975l:gallery:print:sync, GalleryPrintOrderTracker, GalleryMaintenanceTaskProvider, callbackUrl, gallery-print-enabled, gallery-printable-max, gallery-print-provider, gallery-print-sandbox, gallery-print-signature, GalleryPrintFormat, GalleryPrintOrder, GalleryPrintCopy, PrintCopySnapshot, PrintFulfilmentInterface, ProdigiFulfilment, ManualFulfilment, gallery.print_fulfilment, PrintCatalogueProviderInterface, PrintCatalogueImporter, ProdigiCatalogue, import print catalogue, GalleryPrintOrderRepository, claim, findForUser, AccountDataProvider, AccountDataProviderInterface, data export, gallery-print-edition-hold, shop-currency, gallery-print-setup, gallery-print-order, c975lStimulusApp, BasketItemProviderInterface, CatalogueBasketItemProviderInterface, getCatalogueUrl, continue shopping, ShippingBasketItemProviderInterface, shipsParcels, WeighableBasketItemProviderInterface, getWeight, print weight, AutomaticGalleryInterface, gallery.automatic_gallery, GalleryAutomaticProvider, GalleryPrintableProvider, automaticKind, qr code, GalleryShowcaseProvider, ui-showcase-demo-url, block showcase, block-thumbs, block picker silhouette, ui.management_stylesheet, getManagementStylesheets, GallerySnippetBuilder, GalleryJsonLdExtension, gallery_media_json_ld, gallery_json_ld, gallery_index_json_ld, gallery_print_json_ld, buildPrint, Product, Offer, merchant listing, BreadcrumbList, breadcrumb_json_ld, JsonLdBuilder, ImageObject, VideoObject, ImageGallery, acquireLicensePage, license, gallery-license-url, GalleryLicense, Creative Commons, licence icons, gallery-license-icon, tdm-reservation, TDMRep, Licensable, creditText, copyrightNotice, embedUrl, GalleryBlockCacheTagProvider, GalleryBlockCacheInvalidator, GalleryCacheInvalidationListener, BlockCacheTagProviderInterface, block cache, cacheable, invalidateTags, gallery-thumbnail-whole, translation, translate a gallery, multilingual gallery, GalleryTranslator, GalleryTranslatedLocales, GalleryLinkLocalizer, GalleryTranslationPurgeListener, ContentLocaleCrudTrait, ContentLocaleScreen, contenu, language tabs, localized_path, gallery_index_localized, gallery_category_localized, gallery_media_localized, gallery-translation, site_translation, infinite scroll, load more, MEDIAS_PER_PAGE, Pagination, social networks, GallerySocialContentSource, SocialContentSourceInterface, ScopedSocialContentSourceInterface, getScopes, series, GalleryPickableMediaProvider, PickableMediaProviderInterface, findPickable, Ajouter depuis le site, gallery-social-order, gallery-social, BrowsableSocialContentSourceInterface, findContents, getContentScope, Changer le contenu, SocialContentStatusProviderInterface, social badge, GalleryFeedProvider, FeedProviderInterface, Atom feed, /feed/gallery.xml, LinkableRouteCacheTagsInterface, meta description, text.meta_gallery, text.meta_category, text.meta_media, c975l_gallery.print_dir, print directory.
php artisan boost:add-skill c975l/gallery-bundle
Save this content to: .claude/skills/c975l-gallery/SKILL.md
# c975L GalleryBundle
> Photo and video galleries on the c975L core — `GalleryCategory` → `GalleryMedia`, batch upload, automatic thumb/medium/highres derivatives, a public viewer, and two blocks so a gallery can be placed on any composed page.
**Package:** `c975l/gallery-bundle` · **Namespace:** `c975L\GalleryBundle\` · **Twig namespace:** `@c975LGallery` · **Translation domain:** `gallery`
**Key source paths** (relative to the package root):
`src/Controller/GalleryController.php`, `src/Controller/Management/Trait/`, `src/Contract/`, `src/Entity/`, `src/Field/`, `src/Model/`, `src/Repository/`, `src/Routing/GalleryRoutePrefix.php`, `src/Listener/`, `src/Service/`, `src/Service/Fulfilment/`, `src/Twig/Extension/`, `src/Management/`, `src/Form/Block/`, `templates/gallery/`, `templates/print/`, `templates/components/Gallery/`, `templates/blocks/`, `config/configs.json`, `config/services.yaml`
**Related documentation:** this package's `README.md` is the exhaustive reference — every section named below is an anchor in it. The ecosystem's own rules (database-backed configuration, blocks, media library, management contributions) live in `c975l/core-bundle`.
## Quick start
```bash
composer require c975l/gallery-bundle
php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate
php bin/console c975l:config:load-all # seeds config/configs.json into the back office
php bin/console assets:install --symlink
php bin/console c975l:scaffold:install # copies assets/styles/themes/gallery.css into the app
```
```yaml
# config/routes.yaml
c975_l_gallery:
resource: "@c975LGalleryBundle/src/Controller/"
type: attribute
prefix: /
```
Nothing else is registered by hand. The Stimulus entrypoints (`assets/controllers.js` for the public
pages, `assets/controllers-admin.js` for the back office) are picked up by UiBundle's script registry,
and their `importmap.php` entries are written by `Management\ImportmapProvider` on the next
`composer update`. Both join the page's one Stimulus application, `globalThis.c975lStimulusApp`, whichever
c975L barrel loads first starting it: a barrel of its own would build the `live` controller a second time
and a Live Component would answer twice.
`GalleryMedia::$user` is typed against `c975L\ConfigBundle\Contract\UserInterface`: the app's
`App\Entity\User` must implement it. The scaffolded `User` already does.
## The data model
There is **no gallery entity**. A site's galleries *are* its categories — `GalleryCategory` is the
top-level unit, and each holds its `GalleryMedia`.
- `GalleryCategory` — `slug` (the url segment), `title`, `summarySocialNetwork` (rich text, printed
above the grid through `|sanitize_html('c975l.rich')`, never `|raw`, and reused as the page's description metas, the `text.meta_category` sentence standing
in when empty, or closing a summary shorter than 50 characters), `position`, `coverMedia`,
`uncategorized` (the lazily-created catch-all), `automaticKind` (which automatic gallery it is, null
for an ordinary one, see below), `hidden` (kept out of every public page without being deleted, see
*Masking is not deleting*), `license` (a `Model\GalleryLicense` value, `reserved` by default, see
*Structured data*), `medias`. Implements `HasBlocksInterface` (its own editorial heading) and
`TrashableInterface`.
- A site adds fields of its own to a category or a media by implementing
`GalleryCustomizationProviderInterface` (`getCategoryDataFormType()`, `getMediaDataFormType()`),
collected on sight of the interface through `gallery.customization_provider` — nothing to tag. They
live in the `data` JSON column of each, are rendered by `GalleryDataField` on the edit screen, read
back with `getDataValue()`, and travel through the export/import. Declaring nothing means no field.
- `GalleryMedia` — belongs to one category, keyed publicly by `slug` which is **unique within its
category only**. Carries `title`, `data` (the site's own fields, see below), `description` (the caption printed under the photograph and reused
as the page's description metas when set, else composed from site, gallery, title and credits, or the `text.meta_media` sentence when that is under 50 characters, a plain textarea opted into Donovan's rephrase button by `data-ai-rephrase`), `credits`, `rightsReserved`, `position`, `mediaType`
(`image` / a platform name / `embed`, always derived, never set), `externalUrl`, an optional
uploaded video file, and the Vich fields. Implements `TrashableInterface`,
`VichMultiSizeImageInterface`, `VichMediaNamableInterface`, `VichOriginalKeepableInterface`,
`VichWatermarkableInterface`.
## Deleting takes two steps
Both entities carry UiBundle's `TrashableTrait`, and **the delete button removes nothing**. It flags the
row: the category leaves the site, its medias, its heading blocks and every one of their files stay
exactly where they are — the cascade on the medias and `GalleryMediaDerivativeCleanupListener` are
simply never reached.
- The category index switches to what the trash holds with a `trash` query parameter, a category's edit
screen does the same for its medias with `mediasTrash`. A media has a trash of its own, so a photo is
taken off a gallery that stays online, and restoring a category gives back exactly the medias that
were showing when it left.
- Only `GalleryCategoryCrudController::removeForGood()` — reached from the trash's row button
`deletePermanently()` and from the trash's batch "Delete" — and `deleteMediasPermanently()` for a
selection of medias ever remove a row and reach its files. They sit behind the `site-role-admin`
setting; the rest of the gallery back office sits behind `site-role-editor`.
- The batch "Delete" runs `deleteEntity()`, which trashes on the galleries and removes for good in the
trash: `isTrash()` decides, the row's `isDeleted()` only guards against a stale view, and
ConfigBundle's `TrashBatchDeleteTrait::batchDelete()` checks the admin role before the first row
when in the trash and says once how many went. `deletePermanently()`
ignores a category that is not in the trash.
- A trashed category or media answers **410 Gone** on its public url, not 404, straight from the row.
That 410 lasts only as long as the entity can be restored: the permanent deletion is what writes the
"gone" `Redirect` rows that outlive it, and restoring releases any such row left under the url.
- Every listing query leaves the trash out — `findAllOrdered()`, `countVisible()`,
`GalleryMediaRepository::findByCategory()`, `GalleryCategory::getMediasCount()` and
`getCoverOrRandomMedia()`. `findOneBySlug()` and `findOneBySlugInCategory()` are deliberately
unfiltered, the front-office needing the row in hand to answer 410.
- The export/import carries the flag both ways: a category archived out of the trash comes back to it.
- **The likes go with the permanent deletion, never with the trash.** A rating names its owner
(`gallery_media` + id) rather than relating to it, so no foreign key cascades it: `removeForGood()`,
`deleteMediasPermanently()` and `GalleryImportProvider::import()` (which replaces a category's whole
collection) each call `RatingRepository::deleteForOwners()` themselves, once for the whole set and only
after the flush that actually removed the rows. A trashed media keeps its likes — it can come back.
## Masking is not deleting
Both `GalleryCategory` and `GalleryMedia` carry a `hidden` flag, the answer to what is worth keeping and
not worth showing — a gallery being prepared, one taken down for a season, a photograph withheld.
- A masked row stays whole in the back office: the category listing shows it with a switch saving on the
spot, and its own edit screen fills, arranges and trashes its medias as usual.
- It answers **404**, never the trash's 410: masking is reversible, and nothing a change of mind would
have to take back should be told to a crawler. A masked category takes its medias' pages down with it,
those being resolved through their category (`GalleryController::resolveCategory()`).
- `findAllOrdered()` and `countVisible()` drop it, which is what takes a masked gallery off the index,
out of the blocks, out of the sitemap and out of the menu targets — and off the back office's move
targets and block pickers with them, a public page must not be composed on a url answering 404.
- Its photographs leave the automatic galleries too (`c.hidden = false` in `latestMedias()` and
`findPrintable()`) and are not sold as prints (`GalleryPrintService::isPrintable()`, the basket being
reached without ever going through the page that would have said so).
- Masking a category marks none of its medias, exactly as trashing one marks none of them — so showing
it again gives back precisely what was showing before.
- Masking is not privacy: the files stay under `public/medias/`, served to whoever has their address. Never
present `hidden` as a way to keep a photograph confidential.
- The export/import carries both flags: a gallery archived masked comes back masked.
## Public routes and the renamable prefix
| Route name | Url | Renders |
| --- | --- | --- |
| `gallery_index` | `/{prefix}` | one thumbnail per category |
| `gallery_category` | `/{prefix}/{category}` | the category grid, photos and videos alike |
| `gallery_media` | `/{prefix}/{category}/{slug}` | the medium-resolution photo, or the video player |
| `gallery_print_certificate` | `/certificate/{certificate}` | the public check page of one numbered print |
| `gallery_print_file` | `/gallery-print-file/{copy}` | the print file, fetched by the lab through a signed url |
| `gallery_print_callback` | `/gallery-print-callback/{provider}` | where a lab reports an order has moved on |
The last three sit **outside** the renamable prefix: one is printed on paper and has to outlive a rename,
the other is never read by a visitor.
`gallery_category` serves its grid `GalleryController::MEDIAS_PER_PAGE` (60) medias at a time, read from
`?p=`: `category.html.twig` receives `medias` as UiBundle's `Pagination` — countable and iterable, plus
`getCurrentPageNumber()`, `getPageCount()`, `getTotalItemCount()`, `route` and `query()` — and UiBundle's
`infinite-scroll.js` appends the next page as the visitor scrolls, into the grid
`<twig:c975LGallery:Gallery:Medias … infinite="true"/>` marks. A page past the last one answers 404.
The first segment is the `gallery-route-prefix` setting, renamed from the dashboard (`galerie`,
`fotos`) with **no cache to clear**. A route path is compiled into the router cache, so the prefix
cannot *be* the path: the three routes are declared as `/{gallery_prefix}/…` and each carries a
`condition` asking `Routing\GalleryRoutePrefix` whether the segment it was handed is the configured
one. `Listener\GalleryRoutePrefixListener` puts the same value in the router's request context, which
is where the generator takes the missing parameter from.
**Generate urls, never write them.** The route parameter is filled for you, and `localized_path` keeps a
visitor in the language they are reading (see *Translation*):
```twig
{{ localized_path('gallery_index') }}
{{ localized_path('gallery_category', { category: category.slug }) }}
{{ localized_path('gallery_media', { category: category.slug, slug: media.slug }) }}
```
Route *names* never change, whatever the prefix. Renaming the prefix breaks the previously indexed
urls — declare a redirect in ConfigBundle's **Redirections** screen. Renaming a *category* is handled
for you: `GalleryCategoryCrudController::updateEntity()` writes the permanent redirect, plus a
wildcarded row for the medias underneath.
## The automatic galleries
A category carrying an `automaticKind` **holds no media of its own**: it is handed a list at read time.
There are two kinds out of the box — `GalleryCategory::AUTOMATIC_LATEST` (the last additions) and
`AUTOMATIC_PRINTABLE` (the photographs on sale as prints, only on a site that sells them). A kind is
written by `GalleryCategoryRepository::findOrCreateAutomatic()` the first time the galleries are listed
(the back-office listing, the public index or a categories block, whichever comes first) and is a normal
category from then on — renamed, described, given a heading, arranged, linked from a menu, declared in
the sitemap. **Nobody creates one, and it is never an option ticked on one of the site's own galleries.**
Moving it to the trash is how a site says it does not want it: unlike the catch-all, a trashed one is
left exactly where it was put.
**The plumbing is written once, the kinds only answer what they gather.** `Service\GalleryAutomaticProvider`
is the one place every screen asks (`ResetInterface`, so it reads once per request); each kind is an
`AutomaticGalleryInterface` — `getKind()`, `isAvailable()`, `getMedias()` — collected through
`gallery.automatic_gallery`. `Service\GalleryLatestProvider` and `Service\GalleryPrintableProvider` are the
two shipped. A kind answering `false` to `isAvailable()` never gets a row written, so a feature nobody
turned on leaves nothing behind — and `prepare()` drops it from the list it hands back even when a row
already exists, so turning the feature off afterwards takes the gallery off the index too. A kind no
provider answers for at all is kept: it is the ordinary gallery it became the day its bundle went.
`GalleryLatestProvider`'s list comes from `GalleryMediaRepository::findLatest($days, $max)`: a rolling
window of calendar days, today included, falling back on the last day that carries an addition when the
window catches nothing, so the gallery is never empty as long as the site holds a photo. The
`gallery_media_created_at` index on `created_at` is what keeps that read off a full scan.
`GalleryAutomaticProvider`'s own API:
- `getMedias(GalleryCategory $category)` — what that category's kind gathers.
- `ensureCategory(string $kind)` / `ensureCategories()` — the rows written for the kinds available.
- `findAutomaticBySlug(string $slug)` — the automatic gallery a url names, its row written on the way; null,
and nothing written, for any other slug and for a kind turned off.
- `findPreviousAndNext(GalleryMedia $media, GalleryCategory $category)` — the neighbours **within** the
automatic gallery being browsed, which are not the ones the media's own category files it next to.
- `prepare(array $categories)` — hands back the listed categories with the automatic ones among them,
holding their medias. For a caller reading the list itself (the public index, the categories block).
- `hydrate(iterable $categories)` — hands the automatic ones their lists, the others left alone. For a
caller holding entities it did not read (the back-office listing, whose rows EasyAdmin paginates).
- Both also hand **every other listed category** the medias its tile and its media count read, in one
`GalleryMediaRepository::findVisibleByCategories()` query for the whole list, posed through
`GalleryCategory::setLoadedMedias()` — the relation is lazy, so a listing otherwise reads it category by
category. A list of one is left alone, the lazy relation running the one query this would.
`GalleryLatestProvider::getMediasByDay()` cuts that same list into the days its medias were added on —
the back-office screen alone reads it, an upload session being what an admin credits or downloads in one go.
`GalleryCategory::getMediasCount()` and `getCoverOrRandomMedia()` read that handed list rather than the
relation, so a tile, a count and an `og:image` work without knowing which kind of category they hold —
the automatic one showing its **newest** photo rather than one at random.
**Urls stay canonical.** A thumbnail of the automatic gallery links its photo under **its own**
category, never a second path, with `?from=<automatic slug>` over it. `GalleryController::media()`
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 left the window is
browsed as its own category's again.
**Its back-office edit screen is the cross-gallery selection screen**: the same grid
(`templates/management/_gallery_media_tile.html.twig`) cut into one section per day, each thumbnail
naming the gallery the photo belongs to. Credits, rights, downloads and move to trash act on the photo
where it really sits, cover included. What the owning category alone carries is left out: no upload
button, no drag to reorder, no cover radio, no trash view. The media CRUD's category picker leaves out
the automatic and the trashed galleries.
## The library's contact sheet
`GalleryMediaCrudController::index()` answers two screens, told apart by one query parameter:
- **with `category`** — every media screen opened from a gallery carries it along — it redirects to that
gallery's edit screen, where its medias are listed. Unchanged.
- **without it** — the `gallery_media` sidebar entry, the files health check — it renders **the contact
sheet of the whole library**: every gallery's photographs in one grid, `crud/index` overridden with
`templates/management/gallery_media_index.html.twig`, the same tile a category's own grid draws.
`createIndexQueryBuilder()` leaves out the trash on both sides — the media's own flag and its gallery's,
trashing a category flagging the category alone. `configureFilters()` offers the gallery (automatic and
trashed ones excluded, as the category picker does), `printable`, `hidden` and `rightsReserved`; the search
is pinned to `title`, `description` and `credits`.
Overriding `crud/index` replaces EasyAdmin's `main` block, and the filters modal is rendered inside it while
the button opening it comes from `page_actions`: `gallery_media_index.html.twig` therefore includes
`@EasyAdmin/crud/includes/_filters_modal.html.twig` itself, under `filters|length > 0`, or the button throws.
**The tile is the single place a thumbnail's badges are drawn** (`_gallery_media_tile.html.twig`): masked,
on sale, edition size, likes. Both screens hand it `likes`, counted for the whole grid by
`Service\GalleryMediaLikeCounter` — one `RatingRepository::getAggregates()`, and none at all where
`gallery-rating` is off. `selectable` is what turns the selection box on: the category screen has a form
for it to post into, the contact sheet has none.
## Showing a gallery outside its own routes
Two block kinds are contributed to UiBundle (declared in `config/services.yaml`), so a gallery goes on
any page composed in the back office:
| Kind | Shows | Options |
| --- | --- | --- |
| `gallery_categories` | every category, one thumbnail each | optional maximum |
| `gallery_medias` | one category's medias | category, optional maximum, random draw, link to the full category |
Both resolve their content live through `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.
Both are `cacheable: true` all the same. `Service\GalleryBlockCacheTagProvider` (UiBundle's
`BlockCacheTagProviderInterface`) puts every entry under one tag, and
`Listener\GalleryCacheInvalidationListener` drops it whenever a `GalleryCategory` or a `GalleryMedia` is
saved or removed, or one of the settings the blocks are drawn from is: the automatic galleries' four
entries, `gallery-route-prefix` and `gallery-thumbnail-whole`. The tag never goes out before the flush
is over, so a rollback leaves the cache alone. UiBundle only ever invalidates the Block that was
edited, and knows nothing of what these two kinds query at render time — which is what that listener is
for.
Three cases decline the entry and render live instead, an entry never expiring: a block with the random
draw ticked, which would be frozen into one single draw; the gallery of the last additions, whose
photographs leave it as the days go by with nothing saved to drop the tag on; and the listing, for as
long as a gallery has no cover of its own, its tile being drawn at random too.
In a template of your own, the same Twig functions and components are available:
```twig
{# categories, optionally capped #}
{% for category in gallery_block_categories(6) %}…{% endfor %}
{# one category's medias: slug, max, random #}
{% set medias = gallery_block_medias('landscapes', 12, true) %}
{# the grids themselves #}
<twig:c975LGallery:Gallery:Categories categories="{{ categories }}"/>
<twig:c975LGallery:Gallery:Medias category="{{ category }}" medias="{{ medias }}" displayTitle="{{ true }}"/>
```
Anonymous components under `templates/components/Gallery/`: `Categories`, `Category`, `Medias`,
`Media`, `Navigation`, `Previous`, `Next`, `Lightbox`, `Video`, `Credits`.
`Lightbox` writes no markup of its own: it names the two files and the words the link is announced with,
and hands them to UiBundle's `<twig:c975LUi:Image:Zoom>` (`c975l/core-bundle` `^1.26`), which carries the
`<dialog>` and its Stimulus controller. What stays this bundle's is the passe-partout, applied on
`.gallery-media-container .image-zoom__image`, and the three `--gallery-lightbox-*` tokens handed to the
zoom's own on `.gallery-media-container`.
Each kind is drawn as a silhouette in `sass/block-thumbs.scss`, compiled to
`public/css/block-thumbs.min.css` and handed to the back office by `Service\StylesheetProvider`
(`BundleStylesheetManagementProviderInterface`, tag `ui.management_stylesheet`), so the block picker shows
what a kind looks like instead of UiBundle's default frame. A new kind adds its own rule there —
`StylesheetProviderTest` reads the tagged kinds off `config/services.yaml` and fails on one left undrawn.
## Images: what the bundle generates
Three derivatives per uploaded image, generated automatically by UiBundle's `VichImageResizeListener`
through the `VichMultiSizeImageInterface` contract — this bundle only declares the sizes:
| Derivative | Constant | Size | Used by |
| --- | --- | --- | --- |
| thumbnail | `GalleryMedia::THUMBNAIL_SIZE` | 600px longest side | both grids |
| medium | `GalleryMedia::MEDIUM_WIDTH` | 1024px | the media page, and the `og:image` a share carries |
| highres | `GalleryMedia::HIGHRES_WIDTH` | 2048px | the zoom, fetched only on demand |
**The thumbnail file always holds the whole photo** — it is square only for a square photo. What the
grid does with it inside its square tile is the `gallery-thumbnail-whole` setting (`cover` off,
`contain` on): one CSS class, applied on the next request, reversible, nothing regenerated.
Originals can optionally be kept outside the document root (`GalleryMedia::ORIGINAL_DIRECTORY`).
Media files live under `GalleryMedia::MEDIA_DIRECTORY` (`medias/gallery`).
`Service\GalleryMediaArchiver` is what hands those two back: the medias checked on a category's edit
screen are downloaded as one zip, either their highres derivatives or the kept originals
(`GalleryCategoryCrudController::downloadMedias()`, `site-role-editor` like the rest of the screen).
Entries are named after each media's slug and stored rather than deflated, a selection past
`MAX_TOTAL_BYTES` is refused with its size rather than truncated, and a selection with no file at all
gives a message rather than an empty archive. **Do not write a file download of your own** for a
gallery's medias, and do not expose the original directory over http.
`Service\GalleryMediaMover` is the single place a media changes gallery, both ways going through it:
the **Move selection** button of the same toolbar (`GalleryCategoryCrudController::moveMedias()`) and
the category field of a media's own edit form (`GalleryMediaCrudController::updateEntity()`). It moves
the stored file, its `-thumb`/`-highres` siblings, the kept original and the media's own video into
`medias/gallery/{arrival gallery}/`, leaves the old media page redirecting to the new one, suffixes a
slug the arrival gallery already holds, appends the medias to its ranks while closing the gap behind
them, releases a cover pointing at a media that has left, and renumbers the titles when a title root is
given. The destination is chosen explicitly — nothing is preselected in that list, and its last entry
creates the gallery on the spot under the name typed beside it (a slug already taken is refused, never
suffixed, a category's slug being its natural key). **Only the directory moves** — the filename itself
carries the slug the media had at upload and never changes. The files are renamed in `postFlush` so a failed save leaves them where the rows still
point at them; the media is deliberately **left in the gallery's own collection**, that relation being
`orphanRemoval` and removing it there deleting the very row being moved. **Do not reassign
`GalleryMedia::$category` by hand** — the files would stay behind in the gallery the media left.
Both downloads are offered in the medias trash as well, and are the one selection action that does not
filter on `isDeleted` - the state is what keeps a selection posted from the grid away from the permanent
deletion, where reading a file is the same act either way. The category's own **Move to trash** is
rendered at the **foot of the edit screen**, not in the page toolbar (`page_actions` is overridden to
leave it out): next to Save, one row above the photographs being checked, it was clicked for a selection
and took the whole gallery off the site.
```bash
php bin/console c975l:gallery:rebuild-thumbnails [--dry-run] # rewrites every -thumb.webp from the highres
php bin/console c975l:gallery:fill-slugs # backfills slugs on medias predating them
php bin/console c975l:gallery:automatic:dedupe [--dry-run] # merges a "latest-2" into the leftover "latest"
```
**Batch upload** ceilings are checked client-side before the request is sent, because PHP truncates an
oversized batch silently rather than refusing it: `Service\UploadLimits::MAX_FILES` (100) and
`MAX_FILE_SIZE` (20 MiB), each capped in turn by the running PHP's `max_file_uploads`,
`upload_max_filesize` and `post_max_size`. Both constants are there to be raised by an app that knows
its server takes more. The batch screen only ever creates images.
A batch is minutes of transfer then of processing, so the form is armed with UiBundle's
`UploadProgress`: it posts over `XMLHttpRequest`, counts the megabytes as they leave, then says the
files are being processed, the submit button taken away for the whole wait. The controller answers with
the arrival url instead of redirecting, so the "medias added" flash reaches the screen that follows.
## Videos
A media becomes a video by carrying **the url of the page the video is watched on** — nothing to
extract by hand. Whatever it carries, an entry always has its own uploaded still, which is what the
grids show, so one category holds photos and videos alike.
- **Which platforms is UiBundle's question**, not this bundle's: `c975L\UiBundle\Video\VideoPlatform`
is where one is declared. YouTube, TikTok, Vimeo and Dailymotion ship declared. What gets stored is
always the platform's privacy-first embed url (`youtube-nocookie.com`, `dnt=1`).
- An url belonging to no declared platform is stored as pasted, typed `embed`, framed 16/9. There is
deliberately **no "paste your embed code" field** — third-party HTML in the database is an XSS and a
CSP hole.
- A media can also carry an **uploaded video file** (mp4, webm, ogg), played by the browser with the
still as its poster. A media carrying both plays its own copy. It is the one player whose shape
nothing declares — the browser reads it off the file — so it is capped by
`--gallery-video-self-hosted-max-height` (70vh), a height where a framed player's cap
(`--gallery-video-portrait-max-width`) is a width.
- Players render through `<twig:c975LUi:Video:Iframe>`, created client-side only once the visitor has
accepted the cookie banner.
- CSP: UiBundle exposes every declared origin as `%c975l_ui.video.embed_origins%`, for `frame-src`,
`child-src` and any `Permissions-Policy` naming `fullscreen`.
## Likes on a photo
`gallery-rating`, on out of the box, prints UiBundle's rating widget under the media page's photo, asked
for one icon and one only:
```twig
<twig:c975LUi:Rating:Rating ownerType="gallery_media" ownerId="{{ media.id }}" 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. `scale` and `icon` are stated here rather than
left to the site's own `ui-rating-icon` / `ui-rating-scale`, which serve the scales elsewhere. No login is
asked for: an authenticated visitor is keyed on their account, anyone else on a token their own browser
mints. The `rating` table comes with `c975l/core-bundle` `^1.13.1` — an app updating to it generates and
plays a migration, or every media page fails on an unknown table. See UiBundle's *Visitor ratings*.
## Configuration
Everything is in the database, declared in `config/configs.json`, group **gallery**, edited in
EasyAdmin — **never in `.env`, `parameters:` or a Configuration/TreeBuilder class**. Read it with
`ConfigServiceInterface::get('slug')` in PHP or `config('slug')` in Twig.
| Slug | Kind | What it settles |
| --- | --- | --- |
| `gallery-route-prefix` | text | the first url segment (empty falls back to `gallery`) |
| `gallery-thumbnail-whole` | bool | grid tiles `contain` instead of `cover` |
| `gallery-rating` | bool | the heart under a photo, on out of the box (see *Likes on a photo*) |
| `gallery-latest-days` | int | how many days back the automatic gallery reaches (empty or 0 falls back to 7) |
| `gallery-latest-max` | int | how many medias it stops at (empty or 0 falls back to 200) |
| `gallery-license-url` | text | the photographs' terms of use page, published as `license` and as `acquireLicensePage` when no print is offered (see *Structured data*) |
| `gallery-printable-max` | int | how many photographs the prints gallery stops at (empty or 0 falls back to 200) |
| `gallery-print-enabled` | bool | opens the print sale to the public; off out of the box. It hides nothing in the back office: the two print screens, their guided projects and the test-mode tile stay, the indexes warning that the sale is closed (see *Selling prints*) |
| `gallery-print-provider` | text | which lab fulfils the orders (`prodigi` out of the box, `manual` to fulfil them by hand) |
| `gallery-print-api-key` | text, sensitive | the lab's api key |
| `gallery-print-sandbox` | bool | the lab's test mode, on out of the box - toggled from the dashboard tile |
| `gallery-print-signature` | text | the signature laid on print files, a path under `public/`; empty leaves prints unsigned |
| `gallery-social-order` | choice `random`/`oldest` | the order SocialBundle's publication takes the photographs in, `random` out of the box |
| `gallery-style` | choice `light`/`dark` | the ground a photo is shown against; empty keeps the site's own colors |
| `gallery-frame` | choice `none`/`thin`/`wide` | the passe-partout around a displayed media |
| `theme-color-gallery-frame` | text | passe-partout color |
| `theme-color-gallery-nav` | text | arrows |
| `theme-color-gallery-nav-hover` | text | arrows, hovered |
| `theme-color-gallery-nav-background` | text | arrow buttons |
| `theme-color-gallery-backdrop` | text | lightbox backdrop |
| `theme-color-gallery-breadcrumb` | text | breadcrumb |
| `theme-color-gallery-credits` | text | credits line |
| `theme-color-gallery-badge` | text | video badge |
| `theme-color-gallery-badge-text` | text | video badge label |
A `theme-` slug is compiled into a `--c975l-color-gallery-*` custom property by UiBundle's
`ThemeVariablesCssListener`. Four of them are deliberately left empty, their fallback being an
expression that follows a light or a dark gallery rather than a fixed color.
The gallery back office sits behind ConfigBundle's `site-role-editor` setting, except the two permanent
deletions, held at `site-role-admin` (see *Deleting takes two steps*). Every sidebar entry states that same
bar (`MenuProvider`, `'role'` key) and so do the screens themselves (`setPermission(Action::INDEX, ...)` on
each CRUD), so an editor reaches them instead of seeing no entry at all — read by `c975l/core-bundle`
`^1.14.0` and up, earlier ones giving every entry the admin bar.
## Selling prints
Closed to the public unless `gallery-print-enabled` is on, which only governs the public side: the back
office never hides anything because of it. `MenuProvider` always lists the `gallery_print_order` and
`gallery_print_format` entries, `GalleryGuidedProjectProvider` always contributes the two print parcours,
`GalleryShortcutProvider` always offers the test-mode tile, and the bulk **printable** action of a gallery's
screen is always there. While the switch is off, both print indexes draw an `alert alert-warning`
(`_gallery_print_sale_notice.html.twig`, fed `print_enabled` and `print_switch_url` by each CRUD's
`configureResponseParameters()`), the switch's edit url - from UiBundle's `ConfigEditUrlResolver` - being
handed to `site-role-admin` only, ConfigCrudController denying anything below. Requires `c975l/payment-bundle` `^6.18`: a print is sold through
the one basket, this bundle plugging in as a `BasketItemProviderInterface` of kind `gallery_print`. It
also answers `CatalogueBasketItemProviderInterface`, whose `getCatalogueUrl()` hands the basket the path
its "continue shopping" button goes back to — `gallery_index`, PaymentBundle knowing of no shop to send
anybody to on a site running the gallery and the basket alone. It answers `ShippingBasketItemProviderInterface` too, `shipsParcels()` declaring prints as posted to PaymentBundle's shipping health check while `gallery-print-enabled` is on and the catalogue holds a format, and `WeighableBasketItemProviderInterface`, `getWeight()` reading the format's weight frozen into the line (`item.weight`) times its quantity, null when unweighed.
Three entities. `GalleryPrintFormat` is the catalogue — a size, its dpi, its price, its packed `weight` in whole grams (nullable), and the **sku** the
lab knows it by (distinct from the slug; only the sku is ever sent to a lab). `GalleryPrintOrder` is one
consignment. `GalleryPrintCopy` is **the register**: one row per print.
**The catalogue is imported rather than typed** where the lab publishes its range: a driver answering
`PrintCatalogueProviderInterface` (`ProdigiCatalogue` is the shipped one) hands `PrintCatalogueImporter`
the sizes, papers and skus, and the **Import the catalogue** action of `GalleryPrintFormatCrudController`
writes them unpublished and unpriced — the prices are the shop's, never the lab's. Each entry carries its
dpi by its size (300 up to 30 cm on the long edge, 240 up to 40, 200 beyond) and its paper's description as
a `print_paper.*` translation id, written in the site's default locale at import. Run again, the importer
adds what the range gained and refreshes only what is still as shipped — a description still in the
earlier English, a dpi still at the default — never a sentence or a resolution the admin set
(`PrintCatalogueImportReport::$refreshed`). The action is only drawn where a driver answers, a shop
printing by hand writing its formats itself.
`GalleryMedia` carries `printable` (on offer), `hidden` (kept out of every public page without being
deleted, filtered in the repository and 404 in the controller) and `editionSize` (null for an open
edition). Announcing an edition writes its rows at once through `GalleryPrintCopyRepository::openEdition()`,
and `GalleryMediaCrudController::settleEdition()` refuses every later change to the number — raising an
announced edition is a forgery. Selling one claims the lowest free row with a single
`UPDATE … WHERE order_id IS NULL`, so two checkouts on the last copy cannot both win.
A format is only offered for a photograph whose original actually has the pixels and the proportions
(±3 %, `GalleryPrintService::getOffers()`) — nothing is ever cropped to fit a size.
Everything a certificate states is frozen onto the copy at the sale (`PrintCopySnapshot`: format, label,
sku, price, title, credits, issuer). **Read nothing live when drawing a certificate** — the sheet is
signed by hand and posted, so a retitled photograph or a renamed site must not contradict it. The
certificate is a pdf carrying a qr code to its public page, `/certificate/{token}`, which sits outside
the renamable gallery prefix because it is printed on paper.
An open edition goes straight to the lab through Messenger. A limited one stops and waits: two e-mails
go out (the buyer's numbers, and the admin's "sign this"), and the admin releases it from the orders
screen.
A lab is a `PrintFulfilmentInterface`; every implementation is tagged on sight by `TaggedInterfacePass`,
so a site adding its own printer wires nothing. `ProdigiFulfilment` ships, `ManualFulfilment` throws on
purpose so the order stays pending in the back office rather than claiming it was sent. A sold edition is
always announced; the letter asking for a signature only goes out where `gallery-print-edition-hold` is on
and the order really is waiting to be released.
The print file is composed from the untouched original, never from the web derivative, and the signature
is restamped at print resolution (`GalleryPrintFileBuilder`). The lab fetches it through a `UriSigner`
url expiring at seven days; an unsigned request gets 404, not 403.
The file waits in `%kernel.project_dir%/var/gallery-print/`, named `{copy id}-{certificate}.jpg`. A second
context running against its own database (a demo) sets the `c975l_gallery.print_dir` container parameter,
or its prints land among the site's.
What the lab says afterwards reaches the order two ways, and both write through
`GalleryPrintOrderTracker` and nowhere else: the lab's callbacks on `gallery_print_callback` (whose
address `ProdigiFulfilment` sends per order as `callbackUrl`, rather than relying on the lab's dashboard),
and the nightly `c975l:gallery:print:sync`, declared by `GalleryMaintenanceTaskProvider`, which asks each
lab about the orders it holds. **A callback is read as a name, never as a state** — a print lab signs
nothing and that url is public, so the state is then asked of the lab itself. Shipped, the order is
stamped, the buyer gets *your prints are on their way*, and the composed files are discarded. States never
walk backwards, and nothing a lab says moves an order waiting for a signature. The move itself is a single
conditional `UPDATE` (`GalleryPrintOrderRepository::claim()`), so a callback the lab replayed at the same
moment as the nightly command sends one letter and frees the files once. A cancellation writes to
the shop and never to the buyer - the refund is the shopkeeper's decision.
The consuming site routes `GalleryPrintOrderMessage` to an asynchronous transport, so a slow lab never
reaches the payment webhook.
A member's print orders travel in the export of their data (`/account/export`): `Management\AccountDataProvider`
answers ConfigBundle's `AccountDataProviderInterface` under a `prints` key, one entry per order of the
account's baskets (`GalleryPrintOrderRepository::findForUser()`), its lab state and its copies, the basket
number tying it to the `orders` part. An account with no print order adds no key.
## Extending and overriding
- **Templates** — every one of them is overridable from `templates/bundles/c975LGalleryBundle/`,
mirroring the path under `templates/`. Public templates `{% extends 'layout.html.twig' %}` (no `@`),
which the app's own file resolves.
- **Theme** — `assets/styles/themes/gallery.css` is copied into the app by `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. Fonts and site colors are deliberately absent — the gallery reads UiBundle's
`--text` / `--background` / `--font-family-body`, so it looks like the site it is installed on.
- **Blocks on a category** — `GalleryCategory` implements `HasBlocksInterface`; render them with
`<twig:c975LUi:Blocks:Blocks blocks="{{ category.blocks }}"/>`.
- **Fields of the site's own** — implement `GalleryCustomizationProviderInterface` and return a form
type from `getCategoryDataFormType()` / `getMediaDataFormType()`. Nothing to tag: the interface is
collected through `gallery.customization_provider`, read by `GalleryCustomizationRegistry` (first
provider answering wins) and rendered by `GalleryDataField` on the edit screen. The values land in
the `data` JSON column, read back with `getDataValue()`.
- **An automatic gallery of the site's own** — implement `AutomaticGalleryInterface` (`getKind()`,
`isAvailable()`, `getMedias()`). Nothing to tag: `TaggedInterfacePass` collects every implementation
through `gallery.automatic_gallery`, and the category, its place on the index, its menu target and its
sitemap line follow from `GalleryAutomaticProvider` alone.
- **A lab of the site's own** — implement `PrintFulfilmentInterface`, collected the same way through
`gallery.print_fulfilment` and picked by the `gallery-print-provider` setting.
- **Repositories** — `GalleryCategoryRepository::findAllOrdered()`, `findOneBySlug()`,
`countVisible()`, `findOrCreateUncategorized()`, `findOrCreateAutomatic()` (both suffixing a slug
already taken, the latter first taking back an empty ordinary gallery left under `latest`),
`findAutomaticDuplicate()` (the `latest-2` and that leftover, for `c975l:gallery:automatic:dedupe`);
`GalleryMediaRepository::findByCategory()`, `findOneBySlugInCategory()`,
`findPreviousAndNext()`, `findLatest()` (what the automatic gallery shows), `findVisibleByCategories()`
(the same list for several categories at once, grouped by category id), `findWithFilename()`
(the rows naming a file, for the files health check).
`findAllOrdered()` is memoized for the request (`ResetInterface`), its callers knowing nothing of
each other.
What the bundle already contributes to the dashboard, so you do not have to: `MenuProvider` (its own
**Galerie** sidebar section, holding the `gallery`, `gallery_media`, `gallery_print_order` and
`gallery_print_format` entries, rather than the shared
"management" one),
`LinkableRouteProvider` (the index and each category offered as a SiteBundle menu target, tagged `gallery_galleries` through `LinkableRouteCacheTagsInterface` so a menu item pointing at one is cached),
`GallerySitemapProvider`, `GalleryFeedProvider` (ConfigBundle's `FeedProviderInterface`: the latest visible medias as an Atom feed at `/feed/gallery.xml`), `GalleryUrlMetadataProvider`, `GalleryFilesHealthCheckProvider` (kind
`files-gallery`, reporting every media whose image or self-hosted video is gone from the server),
`GalleryExportProvider` /
`GalleryImportProvider` (categories as a zip, files included), `GalleryBackupPathProvider`,
`GalleryBlockOwnerResolver`, `GalleryGuidedProjectProvider` (whose `gallery-print-setup` and
`gallery-print-order` parcours are offered whatever `gallery-print-enabled` says, and `gallery-social` only where SocialBundle is installed), `WhatsNewProvider`, `ImportmapProvider`,
`Service\ScriptProvider`, `Service\StylesheetProvider` (public and management stylesheets both),
`Service\GalleryShowcaseProvider`,
`GalleryShortcutProvider` (the tile toggling the lab's test mode), `GalleryDemoFixtureProvider`,
`Service\GalleryPrintBasketItemProvider` (PaymentBundle's basket, kind `gallery_print`, and the
catalogue its "continue shopping" button returns to, the parcels declared to the shipping health check, and their weight),
`Email\GalleryEmailTemplateProvider`, `Service\GallerySocialContentSource` (UiBundle's `ScopedSocialContentSourceInterface` and `BrowsableSocialContentSourceInterface`, source type `gallery_media`, also `GallerySocialContentSource::SOURCE_TYPE`: one photograph at a time for SocialBundle's publication, a video or an embed going out by its still, never the same twice on a network, read off the configured route prefix since the run has no request; its scopes, `getScopes()`, are the non-automatic visible galleries a SocialBundle series may narrow the draw to), `Service\GalleryPickableMediaProvider` (UiBundle's `PickableMediaProviderInterface`: the visible photographs and uploaded videos, latest first, searched on their title, slug or gallery title, for SocialBundle's "Ajouter depuis le site" to add to a post, each carrying the source type and its id so the pick is held as that content).
## Seeding a demo gallery
`GallerySampleCatalog` holds a made-up gallery once as plain data — three categories of four named
photographs. `GalleryShowcaseProvider` builds the arrays the block showcase renders from it,
`GalleryDemoFixtureProvider` (UiBundle's `DemoFixtureProviderInterface`) hands the lot over to whichever
demo site loads it - this bundle ships no command that writes to a database. Enriching the catalog shows
up in both, and everything a visitor reads is a key of the `gallery` domain.
The photographs come from `PlaceholderMediaProviderInterface`: each media takes the one declared under
its own `gallery/<slug>` key (`keyed_images`), so the showcase and the demo site show the same photograph
under the same title, and falls back on the generic pool, rotated over the catalog, for a slug declared
nowhere. The demo takes its files as a temporary copy, made by UiBundle's `DemoFixtureMediaCopier` — an upload moves the file it is handed. A site
declaring none is seeded with nothing at all: a gallery is its photographs. The showcase's thumbnails
lead to the demonstration site UiBundle's `ui-showcase-demo-url` names, the only place holding these
categories, and render as plain images when none is named. A media's slug is set before the flush, being
half of where its file lands (see `GalleryMedia::getVichMediaPath()`).
Only the categories are yielded, their medias following through the cascade so Vich takes their files
and derivatives off the disk with them.
## Translation
Requires `c975l/core-bundle` `^1.28.0`. A category, a media and a print format are **one row in every language**:
`Service\GalleryTranslator` stores the other languages beside the row in UiBundle's `site_translation`, under
`GalleryTranslator::OWNER_CATEGORY` / `OWNER_MEDIA` / `OWNER_PRINT_FORMAT`. Only `CATEGORY_FIELDS` (`title`,
`summarySocialNetwork`), `MEDIA_FIELDS` (`title`, `description`) and `PRINT_FORMAT_FIELDS` (`label`, `paper`,
`paperDescription`) are translated — slugs, files, SKUs and credits never are. Nothing is read on a site
declaring one language (`isActive()`).
- **Public side** — `GalleryController` doubles its three routes as `gallery_index_localized`,
`gallery_category_localized` and `gallery_media_localized` under `/{_locale}/{gallery_prefix}`, answering in
every language `Service\GalleryTranslatedLocales::all()` returns. What is rendered is handed to
`GalleryTranslator::apply()`, which calls each row's `setTranslated()` so its getters read that language
for the render; `getUntranslated()` still hands back the row's own text. `GalleryBlockExtension` and
`GalleryPrintExtension::getOffers()` apply it too. `Service\GalleryLinkLocalizer` rewrites gallery urls
stored in rich text.
- **Back office** — `Controller\Management\Trait\ContentLocaleCrudTrait` gives the three CRUD controllers a
language screen through ConfigBundle's `ContentLocaleScreen` (`?contenu=xx`): `translationFields()` for
`configureFields()`, `addContentLocaleParameters()` for the tabs, `stageContentLocale()` for the edit form,
and `translateAction()`, icon-only, on the galleries and print formats indexes. The edit templates include
`@c975LConfig/management/_content_locale_tabs.html.twig`. A language screen submits the translated texts
alone, so `GalleryCategoryCrudController::createEditFormBuilder()` skips the slug normalizer and the blocks
listener there, and `GalleryMediaCrudController` skips the watermark listener.
- **Deletion** — `Listener\GalleryTranslationPurgeListener` drops a row's translations on its removal.
- The `gallery-translation` guided project walks a gallery's translation.
## Structured data
The three public pages publish a schema.org graph, built by `Service\GallerySnippetBuilder` and rendered by the
Twig functions `gallery_media_json_ld()`, `gallery_json_ld()` and `gallery_index_json_ld()`
(`Twig\Extension\GalleryJsonLdExtension`) — never by a template of the bundle, so a site overriding a page keeps
its structured data by calling the same function.
A photograph is an `ImageObject`, a video a `VideoObject` whose still becomes the `thumbnailUrl` and whose own
file the `contentUrl` — a video framed from a platform having none, it names its player as `embedUrl` instead
(`GalleryMedia::getEmbedUrl()`). Four properties are what an image search reads a licence from: `creator` and
`creditText` off `GalleryMedia::$credits`, `copyrightNotice` as `© credits` whenever credits are set (not
`$rightsReserved`: a CC photograph is still copyrighted), and `acquireLicensePage` —
the photograph's own url where the page actually prints an offer for it, the terms page otherwise. That terms
page, config `gallery-license-url` (a path made absolute by `UrlHelper` in the extension, or a full url), is also
published as `license` while the gallery keeps all rights reserved; empty, neither is. `GalleryCategory::$license`
offers the Creative Commons 4.0 set and CC0 (`GalleryLicense::all()`, `deedUrl()`, `label()`, `name()`, `icons()`): the deed then
becomes `license`, read off the media's own category rather than the gallery walked through, and is linked by
`Gallery:Credits` in place of the rights-reserved mark, drawn with Creative Commons' icons (`public/icons/cc-*.svg`, CSS masks) and its short name (`CC BY-NC 4.0`), while a reserved one links `gallery-license-url` when set; a category keeping `reserved` has its pages carry
`<meta name="tdm-reservation" content="1">`. An unknown value given to `setLicense()` falls back on `reserved`. The print offer is handed to
`buildMedia()` as its `$printAvailable` argument, the very boolean `gallery_print_available()` gave the page:
the builder asks the shop nothing itself, so the graph can never announce a licence page the visitor is not
being offered. A gallery is an `ImageGallery` whose `mainEntity` is the `ItemList` of the photographs the page
was served with, numbered from `gallery_json_ld()`'s fourth argument, `$offset` - `(page - 1) * perPage` on a
page after the first; the index is that `ItemList` alone.
Two graphs are the exception to "never by a template": `print/_offer.html.twig` publishes the prints as a `Product`
through `gallery_print_json_ld(media, offersByPaper, remaining, imageUrl, url)` (`GallerySnippetBuilder::buildPrint()`,
one `Offer` per `PrintOffer`, price in cents divided by 100, `shop-currency`, `SoldOut` when `remaining` is `0`),
and `Gallery:Navigation` publishes its trail as a `BreadcrumbList` through UiBundle's `breadcrumb_json_ld()`, counts
left out. Item lists, plain text and encoding are UiBundle's `JsonLdBuilder`, injected in the builder.
The urls are resolved by the caller, only a template turning a stored file into an absolute one — the builder
publishes no graph for a media with neither a file to name nor a player to frame.
## Do not
- **Do not copy the old `Lightbox` body into an override.** Its Stimulus controller, `.gallery-media-zoom`, `.gallery-lightbox` and `.gallery-lightbox__image` are gone: a copy keeps a `data-action` nobody listens to, and the click leaves the page for the raw file. Override the component and keep handing `c975LUi:Image:Zoom` its five props.
- **Do not put an `offers` node on the `ImageObject`** — the prints are a `Product` of their own (`buildPrint()`), priced by the offer the page prints.
- **Do not hardcode `/gallery`** in a template, a link or a test. The prefix is admin-editable; use
`localized_path('gallery_category', {category: slug})`.
- **Do not generate a public gallery url with `path()` in a template.** It sends a visitor reading another
language back to the writing one; `localized_path()` keeps them where they are.
- **Do not call `GalleryTranslator::apply()` on a back-office screen, nor persist what it lays on a row.** The
getters then read the translation, and a form built on them would write it over the row's own text.
- **Do not put `createEditFormBuilder()` or `configureResponseParameters()` in `ContentLocaleCrudTrait`.** A
controller's own method shadows a trait's in silence; the controller calls `stageContentLocale()` and
`addContentLocaleParameters()` from its own override instead.
- **Do not add a `.env` variable, a container parameter or a bundle Configuration class** for a
gallery setting. It goes in `config/configs.json` and is read through `ConfigServiceInterface`. The one
container parameter, `c975l_gallery.print_dir`, is a filesystem path, not a setting.
- **Do not write an image resizer, a thumbnail command or a Vich naming rule.** Sizes are declared on
the entity, the work belongs to UiBundle's `VichImageResizeListener`.
- **Do not read an automatic category's `medias` relation** — it is empty by definition. Ask
`GalleryAutomaticProvider::getMedias($category)`, or go through `getMediasCount()` /
`getCoverOrRandomMedia()`, which already do. And do not create a second category of a kind, nor turn
one of the site's own galleries into one.
- **Do not put in `data`** anything the database has to filter, sort or join on, nor anything every
gallery wants — the first stays a real column, the second belongs to the bundle (a caption did, hence
`GalleryMedia::$description`).
- **Do not query a media by slug alone** — a slug is unique only within its category.
- **Do not override EasyAdmin's `main` block without carrying its modals over.** A CRUD declaring filters
keeps its "Filters" button from `page_actions` while the modal it targets lives in `main`; drop it and the
button throws on the first click.
- **Do not raise or lower an announced edition's size.** `settleEdition()` refuses it: the rows are
already written and a certificate already says "3 / 10". Open a new edition instead.
- **Do not read a photograph, a format or the site's name when drawing a certificate.** Everything the
sheet states is frozen on the copy at the sale (`PrintCopySnapshot`) — a signed sheet cannot be allowed
to disagree with the page checking it.
- **Do not claim a copy with a read-then-write.** `GalleryPrintCopyRepository::claimNumber()` is a single
conditional `UPDATE` precisely so two checkouts on the last copy cannot both win.
- **Do not move an order's state with a read-then-write either.** Go through
`GalleryPrintOrderTracker::apply()`, whose `GalleryPrintOrderRepository::claim()` is the same single
conditional `UPDATE` — a replayed callback would otherwise post the shipping letter twice.
- **Do not write a lab driver that creates an order twice when sent twice.** `ProdigiFulfilment` sends an
`idempotencyKey` stable per order and per site; the release button or a lost answer can send one again.
- **Do not print a price with a hardcoded currency.** Read `shop-currency`, as
`templates/print/_offer.html.twig` and PaymentBundle's own basket templates do.
- **Do not render `<twig:c975LPayment:Basket:Navbar/>` in a gallery template.** UiBundle's layout places it
once on every page (`c975l/core-bundle` `^1.31`); a second one is never filled. `Basket:Message` stays.
- **Do not send the catalogue slug to a lab, nor the web derivative as a print file.** The frozen sku is
what a lab knows, and `GalleryPrintFileBuilder` composes from the untouched original.
- **Do not hand VichUploader a plain `File`** when seeding a media — `UploadHandler::hasUploadedFile()`
ignores it in silence, writing the row with no file name and nothing on the disk. Hand it the
`ReplacingFile` UiBundle's `DemoFixtureMediaCopier::copy()` returns.
- **Do not have a fixture provider empty a table** — a demo site keeps its own content in those very tables.
- **Do not list categories or medias with `findAll()` / `findBy()`.** Those see the trash; use
`findAllOrdered()` and `GalleryMediaRepository::findByCategory()`, which do not.
- **Do not count a grid's likes one thumbnail at a time.** `GalleryMediaLikeCounter::count()` reads them
for the whole page in one query, and reads nothing where the site shows no heart.
- **Do not add a listing action to the contact sheet.** A photograph's position and the actions on a
selection belong to the gallery holding it, whose own screen carries the form they post into.
- **Do not remove a media row without dropping its ratings.** Nothing cascades them; go through
`RatingRepository::deleteForOwners('gallery_media', $ids)` after the flush (see *Deleting takes two steps*).
- **Do not call `remove()` on a category or a media**, and do not write a soft-delete flag of your own.
Flag it through `TrashableInterface`; the permanent deletion is the back office's own action.
- **Do not add a video platform here.** Declare it in `c975L\UiBundle\Video\VideoPlatform`.
- **Do not render a third-party iframe directly.** Use `<twig:c975LUi:Video:Iframe>`, which honours the
cookie banner.
- **Do not cache a gallery block without its tag.** They resolve live, so an entry only holds where
`GalleryBlockCacheTagProvider` puts it under the tag `GalleryCacheInvalidationListener` drops — and
where nothing is drawn at random or by a window of days, which that provider declines.
- **Do not add a page layout to this bundle.** A satellite never ships one; templates extend the app's
`layout.html.twig`.
- **Do not build an import command for an existing photo folder.** There is no reliable way to tell
originals from an old gallery's derivatives; photos already on another c975L site move across with
the export/import instead.
Photo and video galleries on the c975L core —
GalleryCategory→GalleryMedia, batch upload, automatic thumb/medium/highres derivatives, a public viewer, and two blocks so a gallery can be placed on any composed page.
Package: c975l/gallery-bundle · Namespace: c975L\GalleryBundle\ · Twig namespace: @c975LGallery · Translation domain: gallery
Key source paths (relative to the package root):
src/Controller/GalleryController.php, src/Controller/Management/Trait/, src/Contract/, src/Entity/, src/Field/, src/Model/, src/Repository/, src/Routing/GalleryRoutePrefix.php, src/Listener/, src/Service/, src/Service/Fulfilment/, src/Twig/Extension/, src/Management/, src/Form/Block/, templates/gallery/, templates/print/, templates/components/Gallery/, templates/blocks/, config/configs.json, config/services.yaml
Related documentation: this package's README.md is the exhaustive reference — every section named below is an anchor in it. The ecosystem's own rules (database-backed configuration, blocks, media library, management contributions) live in c975l/core-bundle.
composer require c975l/gallery-bundle
php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate
php bin/console c975l:config:load-all # seeds config/configs.json into the back office
php bin/console assets:install --symlink
php bin/console c975l:scaffold:install # copies assets/styles/themes/gallery.css into the app
# config/routes.yaml
c975_l_gallery:
resource: "@c975LGalleryBundle/src/Controller/"
type: attribute
prefix: /
Nothing else is registered by hand. The Stimulus entrypoints (assets/controllers.js for the public
pages, assets/controllers-admin.js for the back office) are picked up by UiBundle's script registry,
and their importmap.php entries are written by Management\ImportmapProvider on the next
composer update. Both join the page's one Stimulus application, globalThis.c975lStimulusApp, whichever
c975L barrel loads first starting it: a barrel of its own would build the live controller a second time
and a Live Component would answer twice.
GalleryMedia::$user is typed against c975L\ConfigBundle\Contract\UserInterface: the app's
App\Entity\User must implement it. The scaffolded User already does.
There is no gallery entity. A site's galleries are its categories — GalleryCategory is the
top-level unit, and each holds its GalleryMedia.
GalleryCategory — slug (the url segment), title, summarySocialNetwork (rich text, printed
above the grid through |sanitize_html('c975l.rich'), never |raw, and reused as the page's description metas, the text.meta_category sentence standing
in when empty, or closing a summary shorter than 50 characters), position, coverMedia,
uncategorized (the lazily-created catch-all), automaticKind (which automatic gallery it is, null
for an ordinary one, see below), hidden (kept out of every public page without being deleted, see
Masking is not deleting), license (a Model\GalleryLicense value, reserved by default, see
Structured data), medias. Implements HasBlocksInterface (its own editorial heading) and
TrashableInterface.GalleryCustomizationProviderInterface (getCategoryDataFormType(), getMediaDataFormType()),
collected on sight of the interface through gallery.customization_provider — nothing to tag. They
live in the data JSON column of each, are rendered by GalleryDataField on the edit screen, read
back with getDataValue(), and travel through the export/import. Declaring nothing means no field.GalleryMedia — belongs to one category, keyed publicly by slug which is unique within its
category only. Carries title, data (the site's own fields, see below), description (the caption printed under the photograph and reused
as the page's description metas when set, else composed from site, gallery, title and credits, or the text.meta_media sentence when that is under 50 characters, a plain textarea opted into Donovan's rephrase button by data-ai-rephrase), credits, rightsReserved, position, mediaType
(image / a platform name / embed, always derived, never set), externalUrl, an optional
uploaded video file, and the Vich fields. Implements TrashableInterface,
VichMultiSizeImageInterface, VichMediaNamableInterface, VichOriginalKeepableInterface,
VichWatermarkableInterface.Both entities carry UiBundle's TrashableTrait, and the delete button removes nothing. It flags the
row: the category leaves the site, its medias, its heading blocks and every one of their files stay
exactly where they are — the cascade on the medias and GalleryMediaDerivativeCleanupListener are
simply never reached.
trash query parameter, a category's edit
screen does the same for its medias with mediasTrash. A media has a trash of its own, so a photo is
taken off a gallery that stays online, and restoring a category gives back exactly the medias that
were showing when it left.GalleryCategoryCrudController::removeForGood() — reached from the trash's row button
deletePermanently() and from the trash's batch "Delete" — and deleteMediasPermanently() for a
selection of medias ever remove a row and reach its files. They sit behind the site-role-admin
setting; the rest of the gallery back office sits behind site-role-editor.deleteEntity(), which trashes on the galleries and removes for good in the
trash: isTrash() decides, the row's isDeleted() only guards against a stale view, and
ConfigBundle's TrashBatchDeleteTrait::batchDelete() checks the admin role before the first row
when in the trash and says once how many went. deletePermanently()
ignores a category that is not in the trash.Redirect rows that outlive it, and restoring releases any such row left under the url.findAllOrdered(), countVisible(),
GalleryMediaRepository::findByCategory(), GalleryCategory::getMediasCount() and
getCoverOrRandomMedia(). findOneBySlug() and findOneBySlugInCategory() are deliberately
unfiltered, the front-office needing the row in hand to answer 410.gallery_media + id) rather than relating to it, so no foreign key cascades it: removeForGood(),
deleteMediasPermanently() and GalleryImportProvider::import() (which replaces a category's whole
collection) each call RatingRepository::deleteForOwners() themselves, once for the whole set and only
after the flush that actually removed the rows. A trashed media keeps its likes — it can come back.Both GalleryCategory and GalleryMedia carry a hidden flag, the answer to what is worth keeping and
not worth showing — a gallery being prepared, one taken down for a season, a photograph withheld.
GalleryController::resolveCategory()).findAllOrdered() and countVisible() drop it, which is what takes a masked gallery off the index,
out of the blocks, out of the sitemap and out of the menu targets — and off the back office's move
targets and block pickers with them, a public page must not be composed on a url answering 404.c.hidden = false in latestMedias() and
findPrintable()) and are not sold as prints (GalleryPrintService::isPrintable(), the basket being
reached without ever going through the page that would have said so).public/medias/, served to whoever has their address. Never
present hidden as a way to keep a photograph confidential.| Route name | Url | Renders |
|---|---|---|
gallery_index |
/{prefix} |
one thumbnail per category |
gallery_category |
/{prefix}/{category} |
the category grid, photos and videos alike |
gallery_media |
/{prefix}/{category}/{slug} |
the medium-resolution photo, or the video player |
gallery_print_certificate |
/certificate/{certificate} |
the public check page of one numbered print |
gallery_print_file |
/gallery-print-file/{copy} |
the print file, fetched by the lab through a signed url |
gallery_print_callback |
/gallery-print-callback/{provider} |
where a lab reports an order has moved on |
The last three sit outside the renamable prefix: one is printed on paper and has to outlive a rename, the other is never read by a visitor.
gallery_category serves its grid GalleryController::MEDIAS_PER_PAGE (60) medias at a time, read from
?p=: category.html.twig receives medias as UiBundle's Pagination — countable and iterable, plus
getCurrentPageNumber(), getPageCount(), getTotalItemCount(), route and query() — and UiBundle's
infinite-scroll.js appends the next page as the visitor scrolls, into the grid
<twig:c975LGallery:Gallery:Medias … infinite="true"/> marks. A page past the last one answers 404.
The first segment is the gallery-route-prefix setting, renamed from the dashboard (galerie,
fotos) with no cache to clear. A route path is compiled into the router cache, so the prefix
cannot be the path: the three routes are declared as /{gallery_prefix}/… and each carries a
condition asking Routing\GalleryRoutePrefix whether the segment it was handed is the configured
one. Listener\GalleryRoutePrefixListener puts the same value in the router's request context, which
is where the generator takes the missing parameter from.
Generate urls, never write them. The route parameter is filled for you, and localized_path keeps a
visitor in the language they are reading (see Translation):
{{ localized_path('gallery_index') }}
{{ localized_path('gallery_category', { category: category.slug }) }}
{{ localized_path('gallery_media', { category: category.slug, slug: media.slug }) }}
Route names never change, whatever the prefix. Renaming the prefix breaks the previously indexed
urls — declare a redirect in ConfigBundle's Redirections screen. Renaming a category is handled
for you: GalleryCategoryCrudController::updateEntity() writes the permanent redirect, plus a
wildcarded row for the medias underneath.
A category carrying an automaticKind holds no media of its own: it is handed a list at read time.
There are two kinds out of the box — GalleryCategory::AUTOMATIC_LATEST (the last additions) and
AUTOMATIC_PRINTABLE (the photographs on sale as prints, only on a site that sells them). A kind is
written by GalleryCategoryRepository::findOrCreateAutomatic() the first time the galleries are listed
(the back-office listing, the public index or a categories block, whichever comes first) and is a normal
category from then on — renamed, described, given a heading, arranged, linked from a menu, declared in
the sitemap. Nobody creates one, and it is never an option ticked on one of the site's own galleries.
Moving it to the trash is how a site says it does not want it: unlike the catch-all, a trashed one is
left exactly where it was put.
The plumbing is written once, the kinds only answer what they gather. Service\GalleryAutomaticProvider
is the one place every screen asks (ResetInterface, so it reads once per request); each kind is an
AutomaticGalleryInterface — getKind(), isAvailable(), getMedias() — collected through
gallery.automatic_gallery. Service\GalleryLatestProvider and Service\GalleryPrintableProvider are the
two shipped. A kind answering false to isAvailable() never gets a row written, so a feature nobody
turned on leaves nothing behind — and prepare() drops it from the list it hands back even when a row
already exists, so turning the feature off afterwards takes the gallery off the index too. A kind no
provider answers for at all is kept: it is the ordinary gallery it became the day its bundle went.
GalleryLatestProvider's list comes from GalleryMediaRepository::findLatest($days, $max): a rolling
window of calendar days, today included, falling back on the last day that carries an addition when the
window catches nothing, so the gallery is never empty as long as the site holds a photo. The
gallery_media_created_at index on created_at is what keeps that read off a full scan.
GalleryAutomaticProvider's own API:
getMedias(GalleryCategory $category) — what that category's kind gathers.ensureCategory(string $kind) / ensureCategories() — the rows written for the kinds available.findAutomaticBySlug(string $slug) — the automatic gallery a url names, its row written on the way; null,
and nothing written, for any other slug and for a kind turned off.findPreviousAndNext(GalleryMedia $media, GalleryCategory $category) — the neighbours within the
automatic gallery being browsed, which are not the ones the media's own category files it next to.prepare(array $categories) — hands back the listed categories with the automatic ones among them,
holding their medias. For a caller reading the list itself (the public index, the categories block).hydrate(iterable $categories) — hands the automatic ones their lists, the others left alone. For a
caller holding entities it did not read (the back-office listing, whose rows EasyAdmin paginates).GalleryMediaRepository::findVisibleByCategories() query for the whole list, posed through
GalleryCategory::setLoadedMedias() — the relation is lazy, so a listing otherwise reads it category by
category. A list of one is left alone, the lazy relation running the one query this would.
GalleryLatestProvider::getMediasByDay() cuts that same list into the days its medias were added on —
the back-office screen alone reads it, an upload session being what an admin credits or downloads in one go.GalleryCategory::getMediasCount() and getCoverOrRandomMedia() read that handed list rather than the
relation, so a tile, a count and an og:image work without knowing which kind of category they hold —
the automatic one showing its newest photo rather than one at random.
Urls stay canonical. A thumbnail of the automatic gallery links its photo under its own
category, never a second path, with ?from=<automatic slug> over it. GalleryController::media()
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 left the window is
browsed as its own category's again.
Its back-office edit screen is the cross-gallery selection screen: the same grid
(templates/management/_gallery_media_tile.html.twig) cut into one section per day, each thumbnail
naming the gallery the photo belongs to. Credits, rights, downloads and move to trash act on the photo
where it really sits, cover included. What the owning category alone carries is left out: no upload
button, no drag to reorder, no cover radio, no trash view. The media CRUD's category picker leaves out
the automatic and the trashed galleries.
GalleryMediaCrudController::index() answers two screens, told apart by one query parameter:
category — every media screen opened from a gallery carries it along — it redirects to that
gallery's edit screen, where its medias are listed. Unchanged.gallery_media sidebar entry, the files health check — it renders the contact
sheet of the whole library: every gallery's photographs in one grid, crud/index overridden with
templates/management/gallery_media_index.html.twig, the same tile a category's own grid draws.createIndexQueryBuilder() leaves out the trash on both sides — the media's own flag and its gallery's,
trashing a category flagging the category alone. configureFilters() offers the gallery (automatic and
trashed ones excluded, as the category picker does), printable, hidden and rightsReserved; the search
is pinned to title, description and credits.
Overriding crud/index replaces EasyAdmin's main block, and the filters modal is rendered inside it while
the button opening it comes from page_actions: gallery_media_index.html.twig therefore includes
@EasyAdmin/crud/includes/_filters_modal.html.twig itself, under filters|length > 0, or the button throws.
The tile is the single place a thumbnail's badges are drawn (_gallery_media_tile.html.twig): masked,
on sale, edition size, likes. Both screens hand it likes, counted for the whole grid by
Service\GalleryMediaLikeCounter — one RatingRepository::getAggregates(), and none at all where
gallery-rating is off. selectable is what turns the selection box on: the category screen has a form
for it to post into, the contact sheet has none.
Two block kinds are contributed to UiBundle (declared in config/services.yaml), so a gallery goes on
any page composed in the back office:
| Kind | Shows | Options |
|---|---|---|
gallery_categories |
every category, one thumbnail each | optional maximum |
gallery_medias |
one category's medias | category, optional maximum, random draw, link to the full category |
Both resolve their content live through 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.
Both are cacheable: true all the same. Service\GalleryBlockCacheTagProvider (UiBundle's
BlockCacheTagProviderInterface) puts every entry under one tag, and
Listener\GalleryCacheInvalidationListener drops it whenever a GalleryCategory or a GalleryMedia is
saved or removed, or one of the settings the blocks are drawn from is: the automatic galleries' four
entries, gallery-route-prefix and gallery-thumbnail-whole. The tag never goes out before the flush
is over, so a rollback leaves the cache alone. UiBundle only ever invalidates the Block that was
edited, and knows nothing of what these two kinds query at render time — which is what that listener is
for.
Three cases decline the entry and render live instead, an entry never expiring: a block with the random draw ticked, which would be frozen into one single draw; the gallery of the last additions, whose photographs leave it as the days go by with nothing saved to drop the tag on; and the listing, for as long as a gallery has no cover of its own, its tile being drawn at random too.
In a template of your own, the same Twig functions and components are available:
{# categories, optionally capped #}
{% for category in gallery_block_categories(6) %}…{% endfor %}
{# one category's medias: slug, max, random #}
{% set medias = gallery_block_medias('landscapes', 12, true) %}
{# the grids themselves #}
<twig:c975LGallery:Gallery:Categories categories="{{ categories }}"/>
<twig:c975LGallery:Gallery:Medias category="{{ category }}" medias="{{ medias }}" displayTitle="{{ true }}"/>
Anonymous components under templates/components/Gallery/: Categories, Category, Medias,
Media, Navigation, Previous, Next, Lightbox, Video, Credits.
Lightbox writes no markup of its own: it names the two files and the words the link is announced with,
and hands them to UiBundle's <twig:c975LUi:Image:Zoom> (c975l/core-bundle ^1.26), which carries the
<dialog> and its Stimulus controller. What stays this bundle's is the passe-partout, applied on
.gallery-media-container .image-zoom__image, and the three --gallery-lightbox-* tokens handed to the
zoom's own on .gallery-media-container.
Each kind is drawn as a silhouette in sass/block-thumbs.scss, compiled to
public/css/block-thumbs.min.css and handed to the back office by Service\StylesheetProvider
(BundleStylesheetManagementProviderInterface, tag ui.management_stylesheet), so the block picker shows
what a kind looks like instead of UiBundle's default frame. A new kind adds its own rule there —
StylesheetProviderTest reads the tagged kinds off config/services.yaml and fails on one left undrawn.
Three derivatives per uploaded image, generated automatically by UiBundle's VichImageResizeListener
through the VichMultiSizeImageInterface contract — this bundle only declares the sizes:
| Derivative | Constant | Size | Used by |
|---|---|---|---|
| thumbnail | GalleryMedia::THUMBNAIL_SIZE |
600px longest side | both grids |
| medium | GalleryMedia::MEDIUM_WIDTH |
1024px | the media page, and the og:image a share carries |
| highres | GalleryMedia::HIGHRES_WIDTH |
2048px | the zoom, fetched only on demand |
The thumbnail file always holds the whole photo — it is square only for a square photo. What the
grid does with it inside its square tile is the gallery-thumbnail-whole setting (cover off,
contain on): one CSS class, applied on the next request, reversible, nothing regenerated.
Originals can optionally be kept outside the document root (GalleryMedia::ORIGINAL_DIRECTORY).
Media files live under GalleryMedia::MEDIA_DIRECTORY (medias/gallery).
Service\GalleryMediaArchiver is what hands those two back: the medias checked on a category's edit
screen are downloaded as one zip, either their highres derivatives or the kept originals
(GalleryCategoryCrudController::downloadMedias(), site-role-editor like the rest of the screen).
Entries are named after each media's slug and stored rather than deflated, a selection past
MAX_TOTAL_BYTES is refused with its size rather than truncated, and a selection with no file at all
gives a message rather than an empty archive. Do not write a file download of your own for a
gallery's medias, and do not expose the original directory over http.
Service\GalleryMediaMover is the single place a media changes gallery, both ways going through it:
the Move selection button of the same toolbar (GalleryCategoryCrudController::moveMedias()) and
the category field of a media's own edit form (GalleryMediaCrudController::updateEntity()). It moves
the stored file, its -thumb/-highres siblings, the kept original and the media's own video into
medias/gallery/{arrival gallery}/, leaves the old media page redirecting to the new one, suffixes a
slug the arrival gallery already holds, appends the medias to its ranks while closing the gap behind
them, releases a cover pointing at a media that has left, and renumbers the titles when a title root is
given. The destination is chosen explicitly — nothing is preselected in that list, and its last entry
creates the gallery on the spot under the name typed beside it (a slug already taken is refused, never
suffixed, a category's slug being its natural key). Only the directory moves — the filename itself
carries the slug the media had at upload and never changes. The files are renamed in postFlush so a failed save leaves them where the rows still
point at them; the media is deliberately left in the gallery's own collection, that relation being
orphanRemoval and removing it there deleting the very row being moved. Do not reassign
GalleryMedia::$category by hand — the files would stay behind in the gallery the media left.
Both downloads are offered in the medias trash as well, and are the one selection action that does not
filter on isDeleted - the state is what keeps a selection posted from the grid away from the permanent
deletion, where reading a file is the same act either way. The category's own Move to trash is
rendered at the foot of the edit screen, not in the page toolbar (page_actions is overridden to
leave it out): next to Save, one row above the photographs being checked, it was clicked for a selection
and took the whole gallery off the site.
php bin/console c975l:gallery:rebuild-thumbnails [--dry-run] # rewrites every -thumb.webp from the highres
php bin/console c975l:gallery:fill-slugs # backfills slugs on medias predating them
php bin/console c975l:gallery:automatic:dedupe [--dry-run] # merges a "latest-2" into the leftover "latest"
Batch upload ceilings are checked client-side before the request is sent, because PHP truncates an
oversized batch silently rather than refusing it: Service\UploadLimits::MAX_FILES (100) and
MAX_FILE_SIZE (20 MiB), each capped in turn by the running PHP's max_file_uploads,
upload_max_filesize and post_max_size. Both constants are there to be raised by an app that knows
its server takes more. The batch screen only ever creates images.
A batch is minutes of transfer then of processing, so the form is armed with UiBundle's
UploadProgress: it posts over XMLHttpRequest, counts the megabytes as they leave, then says the
files are being processed, the submit button taken away for the whole wait. The controller answers with
the arrival url instead of redirecting, so the "medias added" flash reaches the screen that follows.
A media becomes a video by carrying the url of the page the video is watched on — nothing to extract by hand. Whatever it carries, an entry always has its own uploaded still, which is what the grids show, so one category holds photos and videos alike.
c975L\UiBundle\Video\VideoPlatform
is where one is declared. YouTube, TikTok, Vimeo and Dailymotion ship declared. What gets stored is
always the platform's privacy-first embed url (youtube-nocookie.com, dnt=1).embed, framed 16/9. There is
deliberately no "paste your embed code" field — third-party HTML in the database is an XSS and a
CSP hole.--gallery-video-self-hosted-max-height (70vh), a height where a framed player's cap
(--gallery-video-portrait-max-width) is a width.<twig:c975LUi:Video:Iframe>, created client-side only once the visitor has
accepted the cookie banner.%c975l_ui.video.embed_origins%, for frame-src,
child-src and any Permissions-Policy naming fullscreen.gallery-rating, on out of the box, prints UiBundle's rating widget under the media page's photo, asked
for one icon and one only:
<twig:c975LUi:Rating:Rating ownerType="gallery_media" ownerId="{{ media.id }}" 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. scale and icon are stated here rather than
left to the site's own ui-rating-icon / ui-rating-scale, which serve the scales elsewhere. No login is
asked for: an authenticated visitor is keyed on their account, anyone else on a token their own browser
mints. The rating table comes with c975l/core-bundle ^1.13.1 — an app updating to it generates and
plays a migration, or every media page fails on an unknown table. See UiBundle's Visitor ratings.
Everything is in the database, declared in config/configs.json, group gallery, edited in
EasyAdmin — never in .env, parameters: or a Configuration/TreeBuilder class. Read it with
ConfigServiceInterface::get('slug') in PHP or config('slug') in Twig.
| Slug | Kind | What it settles |
|---|---|---|
gallery-route-prefix |
text | the first url segment (empty falls back to gallery) |
gallery-thumbnail-whole |
bool | grid tiles contain instead of cover |
gallery-rating |
bool | the heart under a photo, on out of the box (see Likes on a photo) |
gallery-latest-days |
int | how many days back the automatic gallery reaches (empty or 0 falls back to 7) |
gallery-latest-max |
int | how many medias it stops at (empty or 0 falls back to 200) |
gallery-license-url |
text | the photographs' terms of use page, published as license and as acquireLicensePage when no print is offered (see Structured data) |
gallery-printable-max |
int | how many photographs the prints gallery stops at (empty or 0 falls back to 200) |
gallery-print-enabled |
bool | opens the print sale to the public; off out of the box. It hides nothing in the back office: the two print screens, their guided projects and the test-mode tile stay, the indexes warning that the sale is closed (see Selling prints) |
gallery-print-provider |
text | which lab fulfils the orders (prodigi out of the box, manual to fulfil them by hand) |
gallery-print-api-key |
text, sensitive | the lab's api key |
gallery-print-sandbox |
bool | the lab's test mode, on out of the box - toggled from the dashboard tile |
gallery-print-signature |
text | the signature laid on print files, a path under public/; empty leaves prints unsigned |
gallery-social-order |
choice random/oldest |
the order SocialBundle's publication takes the photographs in, random out of the box |
gallery-style |
choice light/dark |
the ground a photo is shown against; empty keeps the site's own colors |
gallery-frame |
choice none/thin/wide |
the passe-partout around a displayed media |
theme-color-gallery-frame |
text | passe-partout color |
theme-color-gallery-nav |
text | arrows |
theme-color-gallery-nav-hover |
text | arrows, hovered |
theme-color-gallery-nav-background |
text | arrow buttons |
theme-color-gallery-backdrop |
text | lightbox backdrop |
theme-color-gallery-breadcrumb |
text | breadcrumb |
theme-color-gallery-credits |
text | credits line |
theme-color-gallery-badge |
text | video badge |
theme-color-gallery-badge-text |
text | video badge label |
A theme- slug is compiled into a --c975l-color-gallery-* custom property by UiBundle's
ThemeVariablesCssListener. Four of them are deliberately left empty, their fallback being an
expression that follows a light or a dark gallery rather than a fixed color.
The gallery back office sits behind ConfigBundle's site-role-editor setting, except the two permanent
deletions, held at site-role-admin (see Deleting takes two steps). Every sidebar entry states that same
bar (MenuProvider, 'role' key) and so do the screens themselves (setPermission(Action::INDEX, ...) on
each CRUD), so an editor reaches them instead of seeing no entry at all — read by c975l/core-bundle
^1.14.0 and up, earlier ones giving every entry the admin bar.
Closed to the public unless gallery-print-enabled is on, which only governs the public side: the back
office never hides anything because of it. MenuProvider always lists the gallery_print_order and
gallery_print_format entries, GalleryGuidedProjectProvider always contributes the two print parcours,
GalleryShortcutProvider always offers the test-mode tile, and the bulk printable action of a gallery's
screen is always there. While the switch is off, both print indexes draw an alert alert-warning
(_gallery_print_sale_notice.html.twig, fed print_enabled and print_switch_url by each CRUD's
configureResponseParameters()), the switch's edit url - from UiBundle's ConfigEditUrlResolver - being
handed to site-role-admin only, ConfigCrudController denying anything below. Requires c975l/payment-bundle ^6.18: a print is sold through
the one basket, this bundle plugging in as a BasketItemProviderInterface of kind gallery_print. It
also answers CatalogueBasketItemProviderInterface, whose getCatalogueUrl() hands the basket the path
its "continue shopping" button goes back to — gallery_index, PaymentBundle knowing of no shop to send
anybody to on a site running the gallery and the basket alone. It answers ShippingBasketItemProviderInterface too, shipsParcels() declaring prints as posted to PaymentBundle's shipping health check while gallery-print-enabled is on and the catalogue holds a format, and WeighableBasketItemProviderInterface, getWeight() reading the format's weight frozen into the line (item.weight) times its quantity, null when unweighed.
Three entities. GalleryPrintFormat is the catalogue — a size, its dpi, its price, its packed weight in whole grams (nullable), and the sku the
lab knows it by (distinct from the slug; only the sku is ever sent to a lab). GalleryPrintOrder is one
consignment. GalleryPrintCopy is the register: one row per print.
The catalogue is imported rather than typed where the lab publishes its range: a driver answering
PrintCatalogueProviderInterface (ProdigiCatalogue is the shipped one) hands PrintCatalogueImporter
the sizes, papers and skus, and the Import the catalogue action of GalleryPrintFormatCrudController
writes them unpublished and unpriced — the prices are the shop's, never the lab's. Each entry carries its
dpi by its size (300 up to 30 cm on the long edge, 240 up to 40, 200 beyond) and its paper's description as
a print_paper.* translation id, written in the site's default locale at import. Run again, the importer
adds what the range gained and refreshes only what is still as shipped — a description still in the
earlier English, a dpi still at the default — never a sentence or a resolution the admin set
(PrintCatalogueImportReport::$refreshed). The action is only drawn where a driver answers, a shop
printing by hand writing its formats itself.
GalleryMedia carries printable (on offer), hidden (kept out of every public page without being
deleted, filtered in the repository and 404 in the controller) and editionSize (null for an open
edition). Announcing an edition writes its rows at once through GalleryPrintCopyRepository::openEdition(),
and GalleryMediaCrudController::settleEdition() refuses every later change to the number — raising an
announced edition is a forgery. Selling one claims the lowest free row with a single
UPDATE … WHERE order_id IS NULL, so two checkouts on the last copy cannot both win.
A format is only offered for a photograph whose original actually has the pixels and the proportions
(±3 %, GalleryPrintService::getOffers()) — nothing is ever cropped to fit a size.
Everything a certificate states is frozen onto the copy at the sale (PrintCopySnapshot: format, label,
sku, price, title, credits, issuer). Read nothing live when drawing a certificate — the sheet is
signed by hand and posted, so a retitled photograph or a renamed site must not contradict it. The
certificate is a pdf carrying a qr code to its public page, /certificate/{token}, which sits outside
the renamable gallery prefix because it is printed on paper.
An open edition goes straight to the lab through Messenger. A limited one stops and waits: two e-mails go out (the buyer's numbers, and the admin's "sign this"), and the admin releases it from the orders screen.
A lab is a PrintFulfilmentInterface; every implementation is tagged on sight by TaggedInterfacePass,
so a site adding its own printer wires nothing. ProdigiFulfilment ships, ManualFulfilment throws on
purpose so the order stays pending in the back office rather than claiming it was sent. A sold edition is
always announced; the letter asking for a signature only goes out where gallery-print-edition-hold is on
and the order really is waiting to be released.
The print file is composed from the untouched original, never from the web derivative, and the signature
is restamped at print resolution (GalleryPrintFileBuilder). The lab fetches it through a UriSigner
url expiring at seven days; an unsigned request gets 404, not 403.
The file waits in %kernel.project_dir%/var/gallery-print/, named {copy id}-{certificate}.jpg. A second
context running against its own database (a demo) sets the c975l_gallery.print_dir container parameter,
or its prints land among the site's.
What the lab says afterwards reaches the order two ways, and both write through
GalleryPrintOrderTracker and nowhere else: the lab's callbacks on gallery_print_callback (whose
address ProdigiFulfilment sends per order as callbackUrl, rather than relying on the lab's dashboard),
and the nightly c975l:gallery:print:sync, declared by GalleryMaintenanceTaskProvider, which asks each
lab about the orders it holds. A callback is read as a name, never as a state — a print lab signs
nothing and that url is public, so the state is then asked of the lab itself. Shipped, the order is
stamped, the buyer gets your prints are on their way, and the composed files are discarded. States never
walk backwards, and nothing a lab says moves an order waiting for a signature. The move itself is a single
conditional UPDATE (GalleryPrintOrderRepository::claim()), so a callback the lab replayed at the same
moment as the nightly command sends one letter and frees the files once. A cancellation writes to
the shop and never to the buyer - the refund is the shopkeeper's decision.
The consuming site routes GalleryPrintOrderMessage to an asynchronous transport, so a slow lab never
reaches the payment webhook.
A member's print orders travel in the export of their data (/account/export): Management\AccountDataProvider
answers ConfigBundle's AccountDataProviderInterface under a prints key, one entry per order of the
account's baskets (GalleryPrintOrderRepository::findForUser()), its lab state and its copies, the basket
number tying it to the orders part. An account with no print order adds no key.
templates/bundles/c975LGalleryBundle/,
mirroring the path under templates/. Public templates {% extends 'layout.html.twig' %} (no @),
which the app's own file resolves.assets/styles/themes/gallery.css is copied into the app by 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. Fonts and site colors are deliberately absent — the gallery reads UiBundle's
--text / --background / --font-family-body, so it looks like the site it is installed on.GalleryCategory implements HasBlocksInterface; render them with
<twig:c975LUi:Blocks:Blocks blocks="{{ category.blocks }}"/>.GalleryCustomizationProviderInterface and return a form
type from getCategoryDataFormType() / getMediaDataFormType(). Nothing to tag: the interface is
collected through gallery.customization_provider, read by GalleryCustomizationRegistry (first
provider answering wins) and rendered by GalleryDataField on the edit screen. The values land in
the data JSON column, read back with getDataValue().AutomaticGalleryInterface (getKind(),
isAvailable(), getMedias()). Nothing to tag: TaggedInterfacePass collects every implementation
through gallery.automatic_gallery, and the category, its place on the index, its menu target and its
sitemap line follow from GalleryAutomaticProvider alone.PrintFulfilmentInterface, collected the same way through
gallery.print_fulfilment and picked by the gallery-print-provider setting.GalleryCategoryRepository::findAllOrdered(), findOneBySlug(),
countVisible(), findOrCreateUncategorized(), findOrCreateAutomatic() (both suffixing a slug
already taken, the latter first taking back an empty ordinary gallery left under latest),
findAutomaticDuplicate() (the latest-2 and that leftover, for c975l:gallery:automatic:dedupe);
GalleryMediaRepository::findByCategory(), findOneBySlugInCategory(),
findPreviousAndNext(), findLatest() (what the automatic gallery shows), findVisibleByCategories()
(the same list for several categories at once, grouped by category id), findWithFilename()
(the rows naming a file, for the files health check).
findAllOrdered() is memoized for the request (ResetInterface), its callers knowing nothing of
each other.What the bundle already contributes to the dashboard, so you do not have to: MenuProvider (its own
Galerie sidebar section, holding the gallery, gallery_media, gallery_print_order and
gallery_print_format entries, rather than the shared
"management" one),
LinkableRouteProvider (the index and each category offered as a SiteBundle menu target, tagged gallery_galleries through LinkableRouteCacheTagsInterface so a menu item pointing at one is cached),
GallerySitemapProvider, GalleryFeedProvider (ConfigBundle's FeedProviderInterface: the latest visible medias as an Atom feed at /feed/gallery.xml), GalleryUrlMetadataProvider, GalleryFilesHealthCheckProvider (kind
files-gallery, reporting every media whose image or self-hosted video is gone from the server),
GalleryExportProvider /
GalleryImportProvider (categories as a zip, files included), GalleryBackupPathProvider,
GalleryBlockOwnerResolver, GalleryGuidedProjectProvider (whose gallery-print-setup and
gallery-print-order parcours are offered whatever gallery-print-enabled says, and gallery-social only where SocialBundle is installed), WhatsNewProvider, ImportmapProvider,
Service\ScriptProvider, Service\StylesheetProvider (public and management stylesheets both),
Service\GalleryShowcaseProvider,
GalleryShortcutProvider (the tile toggling the lab's test mode), GalleryDemoFixtureProvider,
Service\GalleryPrintBasketItemProvider (PaymentBundle's basket, kind gallery_print, and the
catalogue its "continue shopping" button returns to, the parcels declared to the shipping health check, and their weight),
Email\GalleryEmailTemplateProvider, Service\GallerySocialContentSource (UiBundle's ScopedSocialContentSourceInterface and BrowsableSocialContentSourceInterface, source type gallery_media, also GallerySocialContentSource::SOURCE_TYPE: one photograph at a time for SocialBundle's publication, a video or an embed going out by its still, never the same twice on a network, read off the configured route prefix since the run has no request; its scopes, getScopes(), are the non-automatic visible galleries a SocialBundle series may narrow the draw to), Service\GalleryPickableMediaProvider (UiBundle's PickableMediaProviderInterface: the visible photographs and uploaded videos, latest first, searched on their title, slug or gallery title, for SocialBundle's "Ajouter depuis le site" to add to a post, each carrying the source type and its id so the pick is held as that content).
GallerySampleCatalog holds a made-up gallery once as plain data — three categories of four named
photographs. GalleryShowcaseProvider builds the arrays the block showcase renders from it,
GalleryDemoFixtureProvider (UiBundle's DemoFixtureProviderInterface) hands the lot over to whichever
demo site loads it - this bundle ships no command that writes to a database. Enriching the catalog shows
up in both, and everything a visitor reads is a key of the gallery domain.
The photographs come from PlaceholderMediaProviderInterface: each media takes the one declared under
its own gallery/<slug> key (keyed_images), so the showcase and the demo site show the same photograph
under the same title, and falls back on the generic pool, rotated over the catalog, for a slug declared
nowhere. The demo takes its files as a temporary copy, made by UiBundle's DemoFixtureMediaCopier — an upload moves the file it is handed. A site
declaring none is seeded with nothing at all: a gallery is its photographs. The showcase's thumbnails
lead to the demonstration site UiBundle's ui-showcase-demo-url names, the only place holding these
categories, and render as plain images when none is named. A media's slug is set before the flush, being
half of where its file lands (see GalleryMedia::getVichMediaPath()).
Only the categories are yielded, their medias following through the cascade so Vich takes their files and derivatives off the disk with them.
Requires c975l/core-bundle ^1.28.0. A category, a media and a print format are one row in every language:
Service\GalleryTranslator stores the other languages beside the row in UiBundle's site_translation, under
GalleryTranslator::OWNER_CATEGORY / OWNER_MEDIA / OWNER_PRINT_FORMAT. Only CATEGORY_FIELDS (title,
summarySocialNetwork), MEDIA_FIELDS (title, description) and PRINT_FORMAT_FIELDS (label, paper,
paperDescription) are translated — slugs, files, SKUs and credits never are. Nothing is read on a site
declaring one language (isActive()).
GalleryController doubles its three routes as gallery_index_localized,
gallery_category_localized and gallery_media_localized under /{_locale}/{gallery_prefix}, answering in
every language Service\GalleryTranslatedLocales::all() returns. What is rendered is handed to
GalleryTranslator::apply(), which calls each row's setTranslated() so its getters read that language
for the render; getUntranslated() still hands back the row's own text. GalleryBlockExtension and
GalleryPrintExtension::getOffers() apply it too. Service\GalleryLinkLocalizer rewrites gallery urls
stored in rich text.Controller\Management\Trait\ContentLocaleCrudTrait gives the three CRUD controllers a
language screen through ConfigBundle's ContentLocaleScreen (?contenu=xx): translationFields() for
configureFields(), addContentLocaleParameters() for the tabs, stageContentLocale() for the edit form,
and translateAction(), icon-only, on the galleries and print formats indexes. The edit templates include
@c975LConfig/management/_content_locale_tabs.html.twig. A language screen submits the translated texts
alone, so GalleryCategoryCrudController::createEditFormBuilder() skips the slug normalizer and the blocks
listener there, and GalleryMediaCrudController skips the watermark listener.Listener\GalleryTranslationPurgeListener drops a row's translations on its removal.gallery-translation guided project walks a gallery's translation.The three public pages publish a schema.org graph, built by Service\GallerySnippetBuilder and rendered by the
Twig functions gallery_media_json_ld(), gallery_json_ld() and gallery_index_json_ld()
(Twig\Extension\GalleryJsonLdExtension) — never by a template of the bundle, so a site overriding a page keeps
its structured data by calling the same function.
A photograph is an ImageObject, a video a VideoObject whose still becomes the thumbnailUrl and whose own
file the contentUrl — a video framed from a platform having none, it names its player as embedUrl instead
(GalleryMedia::getEmbedUrl()). Four properties are what an image search reads a licence from: creator and
creditText off GalleryMedia::$credits, copyrightNotice as © credits whenever credits are set (not
$rightsReserved: a CC photograph is still copyrighted), and acquireLicensePage —
the photograph's own url where the page actually prints an offer for it, the terms page otherwise. That terms
page, config gallery-license-url (a path made absolute by UrlHelper in the extension, or a full url), is also
published as license while the gallery keeps all rights reserved; empty, neither is. GalleryCategory::$license
offers the Creative Commons 4.0 set and CC0 (GalleryLicense::all(), deedUrl(), label(), name(), icons()): the deed then
becomes license, read off the media's own category rather than the gallery walked through, and is linked by
Gallery:Credits in place of the rights-reserved mark, drawn with Creative Commons' icons (public/icons/cc-*.svg, CSS masks) and its short name (CC BY-NC 4.0), while a reserved one links gallery-license-url when set; a category keeping reserved has its pages carry
<meta name="tdm-reservation" content="1">. An unknown value given to setLicense() falls back on reserved. The print offer is handed to
buildMedia() as its $printAvailable argument, the very boolean gallery_print_available() gave the page:
the builder asks the shop nothing itself, so the graph can never announce a licence page the visitor is not
being offered. A gallery is an ImageGallery whose mainEntity is the ItemList of the photographs the page
was served with, numbered from gallery_json_ld()'s fourth argument, $offset - (page - 1) * perPage on a
page after the first; the index is that ItemList alone.
Two graphs are the exception to "never by a template": print/_offer.html.twig publishes the prints as a Product
through gallery_print_json_ld(media, offersByPaper, remaining, imageUrl, url) (GallerySnippetBuilder::buildPrint(),
one Offer per PrintOffer, price in cents divided by 100, shop-currency, SoldOut when remaining is 0),
and Gallery:Navigation publishes its trail as a BreadcrumbList through UiBundle's breadcrumb_json_ld(), counts
left out. Item lists, plain text and encoding are UiBundle's JsonLdBuilder, injected in the builder.
The urls are resolved by the caller, only a template turning a stored file into an absolute one — the builder publishes no graph for a media with neither a file to name nor a player to frame.
Lightbox body into an override. Its Stimulus controller, .gallery-media-zoom, .gallery-lightbox and .gallery-lightbox__image are gone: a copy keeps a data-action nobody listens to, and the click leaves the page for the raw file. Override the component and keep handing c975LUi:Image:Zoom its five props.offers node on the ImageObject — the prints are a Product of their own (buildPrint()), priced by the offer the page prints./gallery in a template, a link or a test. The prefix is admin-editable; use
localized_path('gallery_category', {category: slug}).path() in a template. It sends a visitor reading another
language back to the writing one; localized_path() keeps them where they are.GalleryTranslator::apply() on a back-office screen, nor persist what it lays on a row. The
getters then read the translation, and a form built on them would write it over the row's own text.createEditFormBuilder() or configureResponseParameters() in ContentLocaleCrudTrait. A
controller's own method shadows a trait's in silence; the controller calls stageContentLocale() and
addContentLocaleParameters() from its own override instead..env variable, a container parameter or a bundle Configuration class for a
gallery setting. It goes in config/configs.json and is read through ConfigServiceInterface. The one
container parameter, c975l_gallery.print_dir, is a filesystem path, not a setting.VichImageResizeListener.medias relation — it is empty by definition. Ask
GalleryAutomaticProvider::getMedias($category), or go through getMediasCount() /
getCoverOrRandomMedia(), which already do. And do not create a second category of a kind, nor turn
one of the site's own galleries into one.data anything the database has to filter, sort or join on, nor anything every
gallery wants — the first stays a real column, the second belongs to the bundle (a caption did, hence
GalleryMedia::$description).main block without carrying its modals over. A CRUD declaring filters
keeps its "Filters" button from page_actions while the modal it targets lives in main; drop it and the
button throws on the first click.settleEdition() refuses it: the rows are
already written and a certificate already says "3 / 10". Open a new edition instead.PrintCopySnapshot) — a signed sheet cannot be allowed
to disagree with the page checking it.GalleryPrintCopyRepository::claimNumber() is a single
conditional UPDATE precisely so two checkouts on the last copy cannot both win.GalleryPrintOrderTracker::apply(), whose GalleryPrintOrderRepository::claim() is the same single
conditional UPDATE — a replayed callback would otherwise post the shipping letter twice.ProdigiFulfilment sends an
idempotencyKey stable per order and per site; the release button or a lost answer can send one again.shop-currency, as
templates/print/_offer.html.twig and PaymentBundle's own basket templates do.<twig:c975LPayment:Basket:Navbar/> in a gallery template. UiBundle's layout places it
once on every page (c975l/core-bundle ^1.31); a second one is never filled. Basket:Message stays.GalleryPrintFileBuilder composes from the untouched original.File when seeding a media — UploadHandler::hasUploadedFile()
ignores it in silence, writing the row with no file name and nothing on the disk. Hand it the
ReplacingFile UiBundle's DemoFixtureMediaCopier::copy() returns.findAll() / findBy(). Those see the trash; use
findAllOrdered() and GalleryMediaRepository::findByCategory(), which do not.GalleryMediaLikeCounter::count() reads them
for the whole page in one query, and reads nothing where the site shows no heart.RatingRepository::deleteForOwners('gallery_media', $ids) after the flush (see Deleting takes two steps).remove() on a category or a media, and do not write a soft-delete flag of your own.
Flag it through TrashableInterface; the permanent deletion is the back office's own action.c975L\UiBundle\Video\VideoPlatform.<twig:c975LUi:Video:Iframe>, which honours the
cookie banner.GalleryBlockCacheTagProvider puts it under the tag GalleryCacheInvalidationListener drops — and
where nothing is drawn at random or by a window of days, which that provider declines.layout.html.twig.How can I help you explore Laravel packages today?