omnibase/gallery is the portfolio of a site of the omnibase family: albums of
photographs - a sign maker's works, a studio's projects, a craftsman's
before and after - listed, filtered by tag and by taxon, each with its
photographs in order. Its namespace is Base\Gallery\; it needs
omnisense/omnibase alone, and registers its back office screens when
omnibase/admin is installed.
What it adds
Album, an omnisense/omnibaseThread(title, text, slug, state, tags, taxa... are the thread's) with the day the work was made, a "featured" flag and its photographs;Photo, a picture with its caption, alternative text, credit, place, cover flag and label.- Three routes under
gallery.path(galerieby default): the list, a taxon's page and an album's page. - schema.org's
ImageGalleryon an album, the albums and taxa in/sitemap.xml. - Three Twig functions and three partials for a site's own pages.
- Two back office screens with Publish and Featured.
- English, French and German messages (domain
gallery).
Pages
Proposer une modification docs/index.md
Installation and configuration
composer require omnibase/gallery:dev-main
// config/bundles.php
Base\Gallery\GalleryBundle::class => ['all' => true],
# config/routes.yaml
gallery_controller:
resource: "@GalleryBundle/src/Controller/Client"
type: attribute
prefix: /
bin/console make:migration && bin/console doctrine:migrations:migrate # gallery_album, gallery_photo
bin/console assets:install public --symlink --relative # bundles/gallery/css/gallery.css
bin/console cache:pool:clear --all # omnibase keeps the routes in a pool
It needs omnisense/omnibase alone; with omnibase/admin installed the back
office screens are registered as well.
Requirements
- PHP 8.2 or later;
- doctrine/orm 2.15 or 3;
- omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x;
- Symfony 6, 7 or 8 (config, dependency-injection, form, http-kernel).
Suggested: omnibase/admin, for the back office screens.
Configuration
# config/packages/gallery.yaml
gallery:
path: galerie # /galerie, /galerie/{slug}; "realisations", "photos", "en/gallery"...
per_page: 24 # albums on a page of the list
taxon_class: Base\Entity\Thread\Taxon # the taxa that have a page of their own
sitemap: true # albums and taxa pages in /sitemap.xml
Every key is optional; the configuration reference says what each one does.
Proposer une modification docs/installation.md
Albums and photographs
What it stores
| Entity | Table | |
|---|---|---|
Base\Gallery\Entity\Album |
gallery_album (JOINED on omnibase's thread, discriminator gallery_album) |
madeAt (the day the work was made), featured, photos |
Base\Gallery\Entity\Photo |
gallery_photo |
album, file (an upload, 32 MB, images), caption, alt, credit, position, cover, label |
Everything else an album has is the thread's: title, excerpt, content
(translated), slug, state, publishedAt, tags, taxa, owners,
connexes (other threads it is linked to).
$album = new Album($user, null, 'Enseigne de la boulangerie');
$album->setMadeAt(new \DateTime('2026-03-14'))->setFeatured(true);
$album->addPhoto((new Photo())->setFile(new File($path))->setCaption('De nuit')->setLabel('Après'));
$album->publish(); // PUBLISH, dated now; publish(false): back to draft
$em->persist($album);
$album->getOrderedPhotos(); // by place, then in the order added
$album->getCover(); // the photo marked, else the first
$album->getPhotosByLabel(); // ['Avant' => [...], 'Après' => [...]]
$album->renumberPhotos(); // places 1, 2, 3...
An album
| Method | Does |
|---|---|
addPhoto($photo) |
adds it once, at the next place |
removePhoto($photo) |
takes it out |
getOrderedPhotos() |
the photographs by place, then in the order added |
getCover(), setCover($photo) |
the photograph marked as the cover, else the first; marking one unmarks the others |
getPhotosByLabel() |
the photographs carrying a label ("Avant", "Après"), by label, in order |
renumberPhotos() |
rewrites the places 1, 2, 3... |
publish(bool $published = true, ?DateTimeInterface $at = null) |
published now (or at $at), the publication date set the first time; false, back to draft |
A photograph
| Method | Returns |
|---|---|
getFile(), hasFile() |
the stored picture's public path; whether one is set, without asking the storage |
getFileUrl() |
its address on the site (the public directory taken off), for JSON-LD |
getAlt() |
what a screen reader says: the alt typed, else the caption, else the album's title; empty without an album |
getOwnAlt() |
the alt as typed, or null |
getCreditLine() |
the credit with its sign: "Anna Meyer" is printed "© Anna Meyer"; a credit opening with ©, (c), "photo" or "foto" is left as it is |
Captions, alts, credits and labels are trimmed; an empty one is null. The
picture is optional at the database level (missable): a photograph whose
file went missing is kept.
The repository
Base\Gallery\Repository\AlbumRepository:
| Method | Returns |
|---|---|
findPublishedPage($page, $perPage, $taxon, $tag, $featured) |
a page of the published albums, featured first, then the most recent work |
countPublished($taxon, $tag, $featured) |
how many |
findLatest($limit = 6, $featured, $taxon) |
the first ones of the list |
findAllPublished() |
every published album, for the sitemap |
findOnePublished($slug) |
one published album |
findNeighbours($album) |
the previous and the next album of the list; none for an album that is not listed |
findTags($taxon) |
the tags of the published albums (of one taxon), by priority then slug |
findTaxa($class) |
the taxa that have a page, by position when the class has one |
findTaxon($slug, $class) |
one of them |
The repository takes its entity class as an argument, so an application's own album class can have a repository extending this one (Extending Album in an application).
Proposer une modification docs/concepts.md
Pages, Twig and styles
The routes
| Route | Path | |
|---|---|---|
gallery_index |
/{path} |
every published album; ?tag=slug filters, ?page=2 pages |
gallery_taxon |
/{path}/{slug} |
the albums of one taxon, with its label and description |
gallery_album |
/{path}/{slug} |
one album |
A taxon's page and an album's page share one pattern: the taxon is looked for first. Give an album another slug than a taxon's.
The list's order: the featured albums first, then the most recent work
(madeAt, else the publication date).
Sitemap
Base\Gallery\EventListener\SitemapListener adds every published album and
every taxon page to omnibase's /sitemap.xml (SitemapEvent::BUILD), the
albums with the day they last changed.
JSON-LD
An album's page carries schema.org's ImageGallery
(Base\Gallery\Service\JsonLd::album()): its name, description, dates,
keywords (its tags) and each photograph as an ImageObject with its
address, caption and credit.
In a site's own pages
{# the six latest albums, the featured ones first #}
{% for album in gallery_albums(6) %}
{% include '@Gallery/client/_album_card.html.twig' with {album: album} only %}
{% endfor %}
{{ gallery_albums(3, true) }} {# three featured albums #}
{{ gallery_albums(4, null, 'enseigne') }} {# four of one taxon, by its slug… #}
{{ gallery_albums(4, null, category) }} {# …or the taxon itself #}
{{ gallery_count() }} {{ gallery_count('enseigne') }}
{% for taxon in gallery_taxa() %}<a href="{{ path('gallery_taxon', {slug: taxon.slug}) }}">{{ taxon }}</a>{% endfor %}
| Function | Returns |
|---|---|
gallery_albums(limit = 6, featured = null, taxon = null) |
the published albums, in the list's order; featured: true only the featured ones, false only the others |
gallery_count(taxon = null) |
how many are published |
gallery_taxa() |
the taxa that have a page (gallery.taxon_class) |
The partials
| Partial | Takes | |
|---|---|---|
@Gallery/client/_album_card.html.twig |
album, heading (the title's level, 3) |
cover, year, title, preview; block gallery_card_meta |
@Gallery/client/_photos.html.twig |
photos, group (the lightbox's name) |
each photograph through omnibase's ` |
@Gallery/client/_filters.html.twig |
taxa, taxon, tags, tag |
the taxa (pages) and the tags (?tag=) |
The pictures go through omnibase's |picture (cards) and |lightbox
(an album's photographs: a thumbnail that opens the full picture, srcset
at 800 and 1600).
The pages and their blocks
@Gallery/client/index.html.twig and @Gallery/client/album.html.twig
extend the site's layout1.html.twig. A site overrides them in
templates/bundles/GalleryBundle/client/ - whole, or block by block by
extending the original with @!Gallery:
{# templates/bundles/GalleryBundle/client/album.html.twig #}
{% extends '@!Gallery/client/album.html.twig' %}
{% block gallery_album_after %}
<a href="{{ path('app_quote', {like: album.slug}) }}">Un projet comme celui-ci ?</a>
{% endblock %}
| Template | Blocks |
|---|---|
index.html.twig |
gallery_head, gallery_filters, gallery_list |
album.html.twig |
gallery_album_head, gallery_album_photos, gallery_album_body, gallery_album_after |
Styles
bundles/gallery/css/gallery.css is linked by the two pages. It is neutral:
it takes the page's font and colour, and a site sets its own on :root (or
on any element above: the cards and the photographs work outside .gallery
too, on a home page).
:root {
--gallery-accent: #d4382d; /* the current filter, labels, focus */
--gallery-accent-ink: #fff;
--gallery-ink: #000;
--gallery-muted: #555;
--gallery-line: #ddd;
--gallery-surface: #f4f3ed; /* behind a picture that loads */
--gallery-gap: 1.5rem;
--gallery-radius: 0;
--gallery-columns-min: 17rem; /* a card's least width */
--gallery-ratio: 4 / 3; /* a card's cover */
--gallery-max: 76rem;
}
No scroll library and no animation tied to scrolling: the page scrolls the way the browser does.
Proposer une modification docs/templates.md
The back office
With omnibase/admin installed, two screens are registered (they are open
to ROLE_ADMIN):
| Screen | Controller | |
|---|---|---|
| Albums | Base\Gallery\Controller\Admin\Crud\AlbumCrudController |
title, state, slug, the day the work was made, publication date, featured, preview, text, tags, taxa, and the photographs |
| Photographs | Base\Gallery\Controller\Admin\Crud\PhotoCrudController |
every photograph, album by album and in order: to fix a caption or a credit without opening the album |
// App\Controller\Admin\DashboardController::configureWidgetItems()
yield MenuItem::section('Galerie', 'fa-solid fa-images')->setSubItems([
MenuItem::linkToCrud(\Base\Gallery\Entity\Album::class, 'Albums', 'fa-solid fa-images'),
MenuItem::linkToCrud(\Base\Gallery\Entity\Photo::class, 'Photos', 'fa-solid fa-camera'),
]);
The photographs of an album
They are typed in the album's own form (Base\Gallery\Form\PhotoType: the
picture, its place, caption, alternative text, credit, label, cover). Saved,
the album rewrites the places 1, 2, 3… in the order typed - a photograph
given the place of another comes before the ones added after it - and keeps
one cover at most (none ticked: the first photograph).
Publier, À la une
Four actions on the list, the detail and the form
(#[AdminAction], POST with a CSRF token): Publier / Dépublier
(Album::publish(): the state, and the publication date the first time),
Mettre à la une / Retirer de la une (featured). Only the one that
applies is shown.
Proposer une modification docs/admin.md
Extending Album in an application
An album is a creation; what a creation is belongs to the site. A sign
maker's realisation has a customer, a town and a technical sheet; a
caterer's event has a number of guests. That is a subclass of Album in
the application - not a field added here, and not another bundle.
The entity
namespace App\Entity\Portfolio;
use App\Repository\Portfolio\RealisationRepository;
use Base\Database\Attribute\DiscriminatorEntry;
use Base\Gallery\Entity\Album;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: RealisationRepository::class)]
#[ORM\Table(name: 'portfolio_realisation')]
#[DiscriminatorEntry(value: 'portfolio_realisation')] // JOINED on thread, like Album on Thread
class Realisation extends Album
{
#[ORM\Column(length: 160, nullable: true)]
protected ?string $client = null;
#[ORM\Column(type: 'json')]
protected array $facts = []; // [{label, value}]: support, format, pose
// getters and setters
}
Its row is three tables joined: thread, gallery_album,
portfolio_realisation. A migration creates the third.
The repository
Every query of Base\Gallery\Repository\AlbumRepository is on Album:
with JOINED inheritance a Realisation comes back as a Realisation, so
the pages, gallery_albums() and the sitemap need nothing. For the site's
own queries, extend it and pass the class:
class RealisationRepository extends AlbumRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Realisation::class); // its queries now return realisations only
}
}
The taxa
The taxa that get a page are those of gallery.taxon_class. A site whose
albums are classified by its own taxon (a subclass of omnibase's Taxon,
with an introduction and a cover, say) names it:
gallery:
path: realisations
taxon_class: App\Entity\Portfolio\Category
/realisations/enseigne is then the page of the Category whose slug is
enseigne, with taxon in the template being that Category.
The pages
Override the templates in templates/bundles/GalleryBundle/client/ and
extend the originals block by block (@!Gallery/...): album is the
subclass, its own fields are there.
{% extends '@!Gallery/client/album.html.twig' %}
{% block gallery_album_body %}
<dl>{% for fact in album.facts %}<dt>{{ fact.label }}</dt><dd>{{ fact.value }}</dd>{% endfor %}</dl>
{{ parent() }}
{% endblock %}
The back office
Extend the album's screen: the class, then the fields.
namespace App\Controller\Admin\Crud\Portfolio;
use App\Entity\Portfolio\Realisation;
use Base\Field\TextField;
use Base\Gallery\Controller\Admin\Crud\AlbumCrudController;
class RealisationCrudController extends AlbumCrudController
{
public static function getEntityFqcn(): string
{
return Realisation::class;
}
public function configureFields(string $pageName): iterable
{
yield from parent::configureFields($pageName);
yield TextField::new('client', 'Client')->setColumns(6);
}
}
The photographs, Publier and À la une come with it. Link the menu to the
subclass (MenuItem::linkToCrud(Realisation::class, …)).
Taking Album over instead
To change Album itself for the whole site, declare
App\Entity\Gallery\Album extends Base\Gallery\Entity\Album: omnibase maps
App\Entity\Gallery over the bundle's entities (App wins), as it does for
its own.
Proposer une modification docs/extending.md
Configuration reference
The gallery tree
Dumped with Symfony's YamlReferenceDumper from
Base\Gallery\DependencyInjection\GalleryConfiguration:
gallery:
# The first segment of the gallery's addresses: /galerie, /galerie/{slug}. No slash.
path: galerie
# Albums listed per page.
per_page: 24
# The taxa that have a page of their own (/galerie/{taxon}): this class and its subclasses. With the default, omnibase's Taxon, a taxon has a page once it classifies a published album; with an application's own class, every taxon of that class has one.
taxon_class: Base\Entity\Thread\Taxon
# The albums and the taxa pages in /sitemap.xml.
sitemap: true
| Key | Default | |
|---|---|---|
path |
galerie |
The first segment of the three routes. One or more URL segments, no slash around. |
per_page |
24 |
|
taxon_class |
Base\Entity\Thread\Taxon |
With the default, a taxon has a page once it classifies a published album. With an application's own class (App\Entity\Portfolio\Category), every taxon of that class has a page, empty or not - and the taxa of the other bundles (a shop's, a FAQ's) never do, nor are they offered in an album's screen of the back office. Listed by their position when the class has that field. |
sitemap |
true |
The parameters are flat: %gallery.path%, %gallery.per_page%,
%gallery.taxon_class%, %gallery.sitemap%.
After changing path, clear the route pool: bin/console cache:pool:clear --all (omnisense/omnibase keeps the routes in a cache pool).
Services
| Service | Role |
|---|---|
Base\Gallery\Repository\AlbumRepository, PhotoRepository |
the queries (Albums and photographs) |
Base\Gallery\Service\JsonLd |
album($album, $url, $absolute), photo($photo, $absolute) |
Base\Gallery\EventListener\SitemapListener |
the albums and the taxa pages of /sitemap.xml |
Base\Gallery\Twig\GalleryExtension |
gallery_albums(), gallery_count(), gallery_taxa() |
Base\Gallery\Form\PhotoType |
a photograph in the album's form |
The admin screens are loaded when omnibase/admin is installed.
Translations
The domain gallery, in English, French and German (ICU format). A site
overrides a message in its own translations/gallery+intl-icu.<locale>.yaml.
Proposer une modification docs/configuration.md
Testing
Running the suite
From the package's checkout, with its dev dependencies:
vendor/bin/phpunit
Inside a host application:
php vendor/bin/phpunit -c vendor/omnibase/gallery/phpunit.xml.dist
In omnisense/omnibase-harness, mounting the checkout in place of the installed package:
docker run --rm --network none \
-v "$PWD":/srv/demo/vendor/omnibase/gallery:ro \
omnisense/omnibase-harness:latest test gallery
On this checkout, in the harness:
OK (30 tests, 85 assertions)
What the tests check
| Test | Checks |
|---|---|
Controller\CrudRequiredFieldsTest |
omnibase/admin's CrudFieldsTestCase on the two screens |
DependencyInjection\ConfigurationTest |
the defaults; a site's own path and taxon class; a path with slashes around is refused |
Entity\AlbumTest |
a photograph joining an album takes the next place, once; the order shown; the cover marked, else the first; one cover at most; removing and renumbering; the photographs by label; publishing sets the state and the date once; featured and made at |
Entity\PhotoTest |
the alt typed, else the caption, else the album's title; no album, an empty alt; the texts trimmed, empty ones null; the credit's sign once; a stored file's public address; cover, place and string form |
Repository\NeighboursTest |
in the middle of the list; the first has no previous, the last no next; an album not listed has none |
Service\JsonLdTest |
an album is an ImageGallery of its photographs in order; without photographs nor dates it keeps to what it knows; the paths left as they are without a host |