Imported from 975L/CoreBundle (
UiBundle/skills/c975l-blocks/SKILL.md). Install upstream withnpx skills add 975L/CoreBundle --skill c975l-blocks. Copyright stays with the author.
c975L UiBundle — blocks
A page is a sorted collection of blocks composed in the back office. Any entity can own one, and any bundle can add a kind, without either knowing about the other.
Package: c975l/core-bundle · Bundle: c975L\UiBundle\ · Twig namespace: @c975LUi · Translation domain: ui
Key source paths (relative to this bundle's directory inside the package):
src/Entity/Block.php, src/Contract/HasBlocksInterface.php, src/Entity/Trait/HasBlocksTrait.php, src/Contract/TrashableInterface.php, src/Entity/Trait/TrashableTrait.php, src/Registry/BlockRegistry.php, src/Entity/Translation.php, src/Service/ContentTranslator.php, src/Repository/TranslationRepository.php, src/Listener/, src/Listener/OwnedBlocksCacheListener.php, src/Listener/RatingCacheListener.php, src/Twig/OwnedBlocksExtension.php, src/DependencyInjection/Compiler/TwigCachePoolPass.php, src/Form/Block/, src/Twig/, src/Management/BlockDataExporter.php, src/Management/BlockDataImporter.php, src/Entity/Rating.php, src/Service/RatingService.php, src/Repository/RatingRepository.php, src/Controller/RatingController.php, src/Service/RatingSnippetBuilder.php, src/Entity/Review.php, src/Enum/ReviewStatus.php, src/Service/ReviewService.php, src/Repository/ReviewRepository.php, src/Controller/ReviewController.php, src/Controller/Management/ReviewCrudController.php, src/Form/ReviewType.php, src/Service/ReviewCollectionSourceProvider.php, src/Contract/ReviewReplyPublisherInterface.php, src/Registry/ReviewReplyRegistry.php, src/Contract/ReviewVerifierInterface.php, src/Registry/ReviewVerifierRegistry.php, src/Service/ReviewTokenSigner.php, src/Service/ReviewNotifier.php, src/Management/ReviewAlertProvider.php, templates/review/, templates/collection/ReviewItem.html.twig, src/Entity/Favorite.php, src/Service/FavoriteService.php, src/Repository/FavoriteRepository.php, src/Controller/FavoriteController.php, src/Contract/FavoriteItemProviderInterface.php, src/Registry/FavoriteItemRegistry.php, templates/blocks/, templates/components/Blocks/, assets/js/block-picker.js, sass/_block-thumbs.scss, config/services.yaml
Related skills: c975l-media, c975l-forms-emails, c975l-ui-assets, c975l-js-testing in this same bundle, and c975l-config, c975l-management in ConfigBundle beside it.
Giving an entity blocks
This is the highest-value thing a satellite bundle can do, and it is three files:
class Product implements HasBlocksInterface
{
use HasBlocksTrait;
#[ORM\ManyToMany(targetEntity: Block::class, cascade: ['persist', 'remove'])]
#[ORM\JoinTable(name: 'shop_product_block')]
#[ORM\OrderBy(['position' => \SortDirection::Ascending])]
private Collection $blocks;
}
<twig:c975LUi:Blocks:Blocks blocks="{{ product.blocks }}"/>
plus a BlockOwnerResolverInterface (supports($ownerType) / find($ownerType, $ownerId)) so a
block's edit screen can find its way back to its owner.
ManyToMany, neverOneToMany—Blockhas no foreign key back to any owner, which is what keeps this bundle out of your domain.- Name the join table explicitly.
- Never add
orphanRemoval:removeBlock()queues the block andBlockRemovalListenerremoves it onpreFlush. - Then
doctrine:migrations:diffandmigrate.
From there the entity's page is composed in the back office with every registered kind — hero, text section, image, slider, cards, cta band — with no template of its own to write.
Deleting an entity in two steps
An entity carrying TrashableInterface and TrashableTrait is never lost in one click: its
back-office delete writes a flag, the row and its files staying where they are until a second,
deliberate deletion. Unlike HasBlocksTrait above, the trait carries its own isDeleted column
mapping — a relation's mapping differs at every use, a boolean column's never does — so generate a
migration after adding it.
The flag is all the bundle gives; the two steps are yours, and always the same three moves:
deleteEntity()sets the flag and flushes instead of callingremove(). That is what spares the cascades and the file listeners, which only ever run from a real removal.- The read paths filter it out, in the repository, never at each caller — one caller forgetting is what puts a trashed entity back on the site. Leave the by-slug lookup unfiltered, so the front office can answer 410 Gone from the row itself.
- The index switches on a
?trash=1query parameter (createIndexQueryBuilder()), where a restore and a permanent delete appear. Hold the permanent one at a higher role: it is the only irreversible one.
Registering a kind
A kind is a service tag, not a class to extend:
ui.block.booking:
class: stdClass
tags:
- name: ui.block
kind: booking
label: label.block_booking
description: label.block_booking_description
translation_domain: my_bundle
category: label.category_booking
form: App\Form\Block\BookingType
template: '@App/blocks/booking.html.twig'
pickable: true
cacheable: true
translatable: 'title, content'
bin/console c975l:ui:block:create scaffolds the form type, the template and the test in a consuming
app. The block's own data is JSON in Block::$data — no column, no migration, ever.
pickable: falsefor a singleton managed from its own dashboard entry and rendered throughBlockRepository::findOneByKind(), so editors cannot create separately-filled copies per page.cacheable— see below. Declare both explicitly; neither has a useful default.contextsrestricts a kind to named contexts. A few are exclusive (the navbar's, aflex_columnsslot's): there the rule is reversed and only a kind that opted in is offered.BlockRegistry::getContexts()reads that list back, a non-empty one telling a kind reachable only inside a parent from one offered everywhere.media_types,media_required,media_multi_uploaddrive the media collection,media_typesbeing enforced on both the input'sacceptand a server-sideFileconstraint. A PDF picked throughmedia_multi_uploadgets its entry'snamefrom its file (MultiUploadMerger).translatablelists the keys of the kind's own data another language may cover, read back withBlockRegistry::getTranslatable()— see below. Nothing declared means nothing translatable, which is what every kind means until it says otherwise: there is no discovery from the form type, a text field holding a css class or an icon name having no business being offered for translation.- A key written
cards[].titlein that same list names a repeated text of a collection the kind holds as json — a FAQ's questions, a grid's cards, the steps of a process, the points of a map — read back withBlockRegistry::getTranslatableCollections()ascollection => the fields of one entry. A translation names it by its place (cards.0.title), so a card that moves or is deleted in the writing language takes its own with it.ContentTranslator::expand()names every one the data holds, andContentTranslator::read()reads one back by that name. - The owning bundle is derived from the template's Twig namespace — no attribute to fill.
Un-registering a kind is safe: a Block row outlives its tag, and render_block() skips an
unknown kind rather than throwing, so uninstalling a bundle blanks its blocks out of the pages instead
of taking them down.
Choosing a kind in the back office
assets/js/block-picker.js puts a visual palette in front of each row's kind <select>: silhouettes
and labels grouped by the categories the select already carries, searched over the label, the
description and the slug, opened as a full-height sheet on a phone and as a centred dialog above it.
The select is never removed, only hidden by CSS (.ui-block-picker-on), so every kind-change rule of
Form\BlockType still reads a posted kind and a browser without JavaScript keeps the plain field.
A registered kind needs nothing to appear there. BlockType writes data-kind-row on the row and
data-label/data-description on each <option> (choice_attr, read off BlockRegistry), which is
all the palette reads — so a kind's label and description are what an editor sees on its tile.
The silhouette is markup plus CSS, no image: five <b> parts arranged per kind in
sass/_block-thumbs.scss, a kind with no rule of its own still drawing the generic one. A page
listing kinds outside /management — a site's public showcase — draws the same tile with
<twig:c975LUi:Blocks:Thumb kind="banner_title"/> and loads
bundles/c975lui/css/block-thumbs.min.css from its own BundleStylesheetProviderInterface; the
back-office gets the rules through sass/management.scss instead.
The render cache
Each block's rendered HTML is cached with an infinite TTL, keyed by block id and locale, and
invalidated by a Doctrine listener watching Block, Media and Translation — a swapped image
or a translated title does not touch the parent block's own fields. bin/console cache:clear invalidates everything, which is how a
template-only release is picked up.
Set cacheable: false whenever the output is not a pure function of (block id, data, locale):
- it embeds a per-request form (a cached CSRF token would be served to every visitor);
- it reads another block's data (a pointer kind rendering a site-wide singleton);
- it queries entities the invalidation listener does not watch.
When in doubt, cacheable: false — the cost is one avoidable render, not a correctness bug. To
keep a kind cacheable while reading outside data, implement BlockCacheTagProviderInterface and
invalidate your own tag where that data changes.
An owner's whole run as one entry
{{ render_owned_blocks(product) }}
render_owned_blocks() (Twig\OwnedBlocksExtension) renders what Blocks:Blocks renders for any
HasBlocksInterface, kept as one entry per owner and locale, so a hit reads neither the blocks,
their medias nor their slots — the owner's own row is all the request loads.
- Tagged with every block's
block_{id}and kind tags,blocks_all, andOwnedBlocksExtension::ownerTag()(owned_blocks_<short class>_<id>, nothing to declare). - A block added, removed or moved writes only the join table:
OwnedBlocksCacheListenerreads the owner's collection off the unit of work and empties its tag after the flush. A block edited in place reaches the entry through its ownblock_{id}. - One block refusing the cache (uncacheable kind, a resolver's
null) stores nothing for the run, which falls back onrender_block()'s per-block entries. - Live for an editor (
site-role-editor), whileBlockRenderContexthas the cache disabled (a preview), and outside a request. - On a miss
BlockRepository::preloadTree()reads the run, its medias and its slots one query per level;BlockExtension::renderNested()lays the CSP nonce and the localized links once, outside the stored html, on the hit as on the miss.
{% cache %} fragments share the same tags
DependencyInjection\Compiler\TwigCachePoolPass aliases twig.cache (twig/extra-bundle) to
cache.app.taggable, the pool every c975L bundle empties its tags in. A fragment tagged with the tag
the entity behind it already empties — a collection source's cacheTags, ui_reviews,
ui_rating_cache_tag(), ConfigBundle's url_metadata — goes stale on the same save as the blocks,
with no listener of its own. blocks_all makes one go on a release too.
Translating a block's content
Only on a site offering several languages (SiteLocales::isMultilingual(), see c975l-config) —
everywhere else none of this runs and nothing changes.
Entity\Translation (site_translation) holds one field of one thing said in one other language,
keyed by ownerType / ownerId / field / locale. It names its owner (ui_block, ui_media)
rather than pointing at it, like Favorite and Rating: no foreign key, which is why
TranslationPurgeListener deletes a block's - and a media's - rows on their postRemove.
The default language is never stored. It stays in Block::$data and plays the part of the msgid,
so a single-language site holds not one row here.
Service\ContentTranslatoris the one service reading and writing them, for a page's own fields as much as a block's.BlockExtensionlays what it returns over the stored data — a field nobody translated keeps the text it was written in, and the block templates never hear about any of this.- The language screen is
BlockTypegiven atranslation_locale: the same fields, rendered unmapped, filled with what that language says or the source text between brackets where it says nothing yet, and narrowed to the kind'stranslatablelist. The kind is locked and the entrance animation left out — neither says anything a language could change. - A collection is rendered there with its entries and without Add or Delete: a page is composed once, in the language it was written in, and a card taken away from a language screen would be taken away from every language at once. A card dragged elsewhere takes its translations to the place it lands, and one whose words are gone loses them, compared on the words alone so a rich text editor's re-serialisation is not read as a rewrite.
- What that form writes is staged, not stored: a form's POST_SUBMIT fires before the root form is
validated, so
ContentTranslator::stage()holds it untilTranslationWriteListenerwrites it on the flush that saves the block. A field handed back still holding the bracketed source is stored as nothing. - A block's own links follow the language being read: the html it renders is offered to
Registry\InternalLinkLocalizerRegistry, which chains the registeredContract\InternalLinkLocalizerInterface— each bundle rewriting the paths it recognises and giving everything else back untouched, a link being as often a word inside a rich text as a field of its own. Applied outside the render cache, at the outermostrender_block()like the nonce, so a page gaining a language changes the links pointing at it with no entry to invalidate. Implement it in the bundle owning the urls; a site with none registered leaves every link exactly as it was stored. A link a template generates is ConfigBundle'slocalized_path(), seec975l-config. - A block's link fields are
Form\LinkTargetType(button,card,collection,cta_band,hero,portfolio_grid,slide,video_grid): a searchable list of pages and sections plus any address typed by hand. The list comes fromContract\LinkTargetProviderInterface::linkTargets()(label => stored value, e.g.page:12#offer-34), auto-discovered byLinkTargetProviderPassintoRegistry\LinkTargetRegistry; the same bundle maps those values back to urls through itsInternalLinkLocalizerInterface. Never print a link field's raw value as visible text — a storedpage:code only becomes a url insidehref. Service\TranslationFormContextcarries the language being written for what cannot be handed the form's options — the AI toolbar of a field several levels below the sub-form.- A media's own texts are translated too (
ui_media,Service\MediaTranslator):label,descriptionandaltlive on theMediarow and not inBlock::$data, so the kind'stranslatablelist cannot declare them.BlockExtensionlays the language on the entities as an unmapped overlay (Media::setTranslated()), which the getters prefer and Doctrine never persists — the templates go on sayingmedia.label. The language screen adds oneMediaTranslationTypeper media, namedmediaTranslation_<id>and carryingdata-media-translationfor a guided step to point at, plus amediaTranslationsRenderedmarker without which nothing is staged: an unrendered child submits null, which would otherwise take the translations away on every save. Only the fields the media says something in are offered; the file, its link, its credits and its dimensions never are. - The render cache is already keyed by locale, and
BlockCacheInvalidationListenerwatchesTranslationtoo, a row of another table otherwise touching no block — aui_mediarow resolving to the block its media hangs from.
Anchors, containers, edit overlay
Any page-section kind can carry an anchor, which is what makes it a menu target; the whole page tree is walked, nested sections included. Container kinds hold other blocks in slots, and a saved block is dragged from one collection to another.
BlockEditUrlProviderInterface returns an edit url per block id so an editor gets the hover Edit
button on the rendered page — call Service\LegalModelEditUrl::build() first in your implementation,
a legal_model block being edited on its own screen. BlockLocationProviderInterface tells the
screens listing one kind site-wide where each block actually lives.
An entity of the site's own gets the same button: wrap its public page in
<twig:c975LUi:Edit:Entity entity="{{ resistant }}">, and entity_edit_url() finds its CRUD by name
(App\Entity\Resistant → App\Controller\Management\ResistantCrudController), answering null below
site-role-editor. The wrapper goes around a render cache, never inside it. Inside a cached
fragment, mark elements neutrally instead — data-edit-entity="<kind>:<id>" on a card, the kind being
the CRUD's route path, data-edit-field="<property>" on a section of a fiche — and the edit-pencils
controller the layout mounts for editors (from entity_edit_pattern()) turns them into edit urls.
Painting a section as a flat
hero, feature_bar, text_section, collection, cta_band, flex_columns and section_cards
carry an optional Background field (HasBackgroundFieldTrait), painting the section as a
full-width flat: light grey, the site's primary color, or dark. It is a field and not a token because
a colored band has to invert everything it holds — title, muted text, eyebrow, dividers,
translucent chips, and the primary CTA, itself a --primary flat turning white-on-color over one.
Unset keeps meaning "no flat", so a block saved before the field renders exactly as it did. A section
used as a column of a flex_columns drops the page gutters it would otherwise read twice, and its
flat paints that column rather than breaking out of the row.
Setting a block aside
Block::$hidden keeps a block on the page and renders it nowhere: its fields, its medias, its slots and
its place in the order are untouched, which is what lets a page be seen without a block instead of the
block being deleted and built back afterwards. The eye button of each row's toolbar toggles it, driving
the row's own hidden checkbox (Form\BlockType, never shown) so the state is stored by the same save as
every other field.
BlockExtension::renderBlock() is the single gate — a hidden block returns an empty string, wrappers
included, whether it is a page's own block or a slot of a container — and the check sits before the
render cache, so toggling the flag changes the page with no entry to invalidate. A template laying its
blocks out in cells of its own has to drop them earlier still: Blocks.html.twig filters them before its
card grouping counts kinds, and Section/FlexColumns, Section/Cards and Video/Grid before they count
their slots, a hidden one otherwise holding an empty cell — or a whole row — open. The flag travels
through BlockDataExporter/BlockDataImporter, an archive with no hidden key landing visible.
The contact graph
The contact_details kind has two outputs off the same fields: the panel a visitor reads, and the
schema.org JSON-LD graph ContactSnippetBuilder assembles, every field optional and an empty one
dropped rather than published blank.
A bundle holding the urls of the profiles naming that same business elsewhere — a Google listing, a
social account — implements SameAsProviderInterface and they reach that graph's sameAs, the
property tying the site and those profiles into one entity. Same auto-discovery as everything above,
no tag needed; the registry is read at render time, so urls kept in the database are current, and it
de-duplicates across providers. Do not add a field to the block for them — the bundle owning the
profiles is the only one that knows their urls.
The questions block
The faq kind is the other kind publishing structured data off its own fields: a list of questions
each unfolding under its own <summary>, and a schema.org FAQPage payload built from those very
questions, which is what puts the answers in a search result. It is <details>/<summary> and not a
script, so the accordion works before any JavaScript loads and a printed page shows every answer.
The payload is published in one column only. schema.org reads a FAQPage as one ordered list, and
the block's two-column option says the page is not one — so a two-column FAQ renders the questions and
nothing else. Answers are stripped of their markup on the way into the payload.
The map block
The map kind holds a list of places, each set either by its address or by its GPS
coordinates — the editor says which, rather than the form guessing from which field they filled.
An address is geocoded once, on save (MapGeocoder, Nominatim, aliasable through
MapGeocoderInterface), and the coordinates are kept beside it, so a page carrying a map never
geocodes while a visitor waits and a block saved again is not geocoded again.
The list of places is the content, the map is what the browser makes of it: the list is rendered server-side, each entry linking to the place on OpenStreetMap, and the map is drawn over it. That is what a visitor with no JavaScript, a browser that never got the library, and a Google key that was refused all keep — and the only version a keyboard and a screen reader can work through.
A long listing can go into a picker: the block's list option (list="select" on
<twig:c975LUi:Map:Map>, full by default) renders a hidden <select> that assets/js/map.js
reveals in the list's place once the map is scheduled. Picking a place brings the map onto its marker,
even when picked before the library arrived; a map that never draws, a visitor with no JavaScript and
a printed page all keep the written-out list.
A single place can be drawn with an image of its own: <twig:c975LUi:Map:Map> reads an optional
icon url on a point and draws that marker with it, in both providers, a point naming none keeping
the pin sass/_map.scss paints. It is for a listing whose places are of several sorts and is read by
telling them apart — no field of the map block, whose editor places one sort at a time, but a key a
bundle or an app building its own points passes through.
Which service draws it is site-wide, never a field of the block: ui-map-provider picks between
leaflet and google, ui-map-google-api-key holds the key. Map\MapProvider declares a provider —
tile server, attribution, whether it needs a key, whether it needs consent — and ui_map_settings()
hands that to the component. Google writes cookies and is billed per load, so it waits on the same
content consent category as video_iframe; OpenStreetMap's tiles write none and are never gated.
Leaflet is served by this bundle (public/js/leaflet.js, declared in config/vendor-assets.json),
appended on demand like cookie-consent.js serves its banner — a consuming app installs nothing. The
origins are three container parameters, one per directive (c975l_ui.map.img_origins, .script_origins,
.connect_origins), for the site to name in its own policy; a site on OpenStreetMap names the image
one alone. Google's API also injects <style> into the head, which the nonce-only style-src the c975L
sites run blocks outright — turning it on means giving up that hardening.
Separately, the contact_details block builds its own Google Maps link from its coordinates or
its address when the box is ticked (GoogleMapsLinkBuilder): a plain Maps url, free and loading no
script — nothing to do with the billed JavaScript API above.
Legal models
The legal notice, privacy policy, terms of sales and use, cookies and copyright are this bundle's:
the legal_model block renders them and the Legal models screen customizes them section by
section. A site running a shop with no page management needs them just as much. Do not write legal
text into a template, and do not duplicate the models in a satellite bundle.
A model states only the processing the site actually does: site-has-accounts (bool, true by
default) drops the account, password and login-identifier passages of the privacy policy on a site
holding no accounts, a text describing processing that does not exist being worse than a short one.
A setting rather than a check on a route or a form — the login exists everywhere for the
administrator, and accounts outlive a closed registration.
Visitor ratings
Anything at all is rated by a visitor, blocks or not — the rated thing is named, never related:
Entity\Rating stores an ownerType/ownerId pair, the same vocabulary BlockOwnerResolverInterface
round-trips, so no bundle maps a collection it never reads.
<twig:c975LUi:Rating:Rating ownerType="book" ownerId="{{ book.id }}"/>
ui_rating(ownerType, ownerId) returns {average, count, scale, icon}, and
ui_ratings(ownerType, ids) one tally per id in a single query — what a listing needs to avoid an
N+1.
On a listing, two more props turn the widget into what a catalog card has room for:
{% set ratings = ui_ratings('book', books|map(b => b.id)) %}
<twig:c975LUi:Rating:Rating ownerType="book" ownerId="{{ book.id }}" compact="true" :aggregate="ratings[book.id]"/>
-
compactprints the score and nothing else — no "37 avis", and nothing at all before the first vote, the empty row of icons saying it already. Except on a scale of 1, where the count is the reading and there is no average to drop it for. -
aggregatehands the widget the tally the listing already read, so thirty cards run no query of their own; left out, each one reads its own.ui_rating()takes it as a fifth argument, and reads anything but that shape as no vote at all — a catalog card is no place to raise an error. -
Only a listing rendered outside the block cache asks for it. The html of a cached block is shared by every visitor and its averages would freeze with it, which is why
Book:Books,Strip:Cardsand ShopBundle'sProduct:Productsall take the widget as an opt-in prop their index pages alone pass. -
localenames the language the rated thing is written in — a book's page reading in French whatever language the visitor arrived in. It carries the words of the tally rendered server-side and the ones handed toassets/js/rating.js, so a vote reads in the language of the page it was cast on; left out, the widget speaks the visitor's own. -
The icon and the scale are the site's, two
configs.jsonentries of thegeneralgroup:ui-rating-icon(star,heart,thumbs-up,face-smile) andui-rating-scale(1 to 10). A scale of 1 is a "like": the count replaces the average and clicking again takes the vote back. -
The scale is read server-side, never off the request — a forged POST would otherwise store a 10 on a site rated out of 5.
-
One vote each, without a login: an authenticated visitor is keyed on their account, anyone else on a 32-hex token their own browser mints on the click and never before, which is what keeps the widget out of consent territory. Both land in the same
votercolumn under one unique constraint. -
POST /rating/{ownerType}/{ownerId}(ui_rating_vote) takes no CSRF token and answersno-store: a token would open a session whoseSet-Cookiethe shared cache would hand to the next visitor. A json body, anOrigin/Refererof this site and theui_ratinglimiter stand in its place. -
A vote the limiter turns down is told apart from every other failure — the widget writes
label.rating_throttledinto its tally, notlabel.rating_error: a visitor rating a whole catalog reaches that ceiling in the ordinary course of browsing, and "come back in a few minutes" is the one thing that answers it. Either key is overridden in the app's owntranslations/. -
RatingSnippetBuilderpublishes the tally to a search engine —build($ownerType, $ownerId), orbuildFromAggregate($aggregate)off a tally the listing already read. It returns a fragment, never a graph: schema.org reads anAggregateRatingas a property of the thing rated, so the bundle owning that thing nests the node in its own. An owner nobody voted on returns[], a zeroed node being what Google rejects the whole rich result for. -
A fragment printing an average can be cached with the tag a vote empties:
RatingCacheListenerinvalidatesui_rating_cache_tag(ownerType, ownerId)andui_rating_type_cache_tag(ownerType)on any vote cast, changed or withdrawn, a review's score included, once per flush. The first is for one owner's tally (a product sheet, itsaggregateRating), the second for a grid of several owners of a type. The voter's own score is never in the fragment:assets/js/rating.jspaints it from the browser. -
Nothing cascades. Whichever service deletes the rated row for good calls
RatingRepository::deleteForOwner()— on the permanent delete only, never on a trash: a restored entity has to find its notes where it left them. A whole set goes throughdeleteForOwners($ownerType, $ownerIds), one query for the lot rather than one per row.
Written reviews
A rating is one anonymous click; a review is a text, a name and a decision to publish. Two things,
one owner vocabulary: Entity\Review carries the same ownerType/ownerId pair as Rating, both
nullable — filled for a review about one listed thing, null for a review about the site itself.
The same rows hold what a visitor wrote here and what a platform was asked for (see
c975l/social-bundle's ReviewsSourceInterface). Only source tells them apart: Review::SOURCE_SITE
('site') for the first, the platform's own name for the second. What separated them was never worth
two entities — ten columns out of eleven were the same.
{# The whole section - the published reviews and the fold the form opens in - rendered once and kept #}
{{ ui_reviews_section('book', book.id) }}
ui_reviews_section() holds its render in the same tagged cache the page's blocks are in, emptied on
every review written, imported or moderated, and answers an empty string while ui-enable-reviews is
off. Reach for ui_reviews() and the Review/List component directly only to lay the section out
differently — the caching is then yours to do.
ui-enable-reviews(bool,falseby default) gates the whole feature at once: the public form, the management screen, the collection source. Off,ui_reviews()returns[]rather than failing. It is flipped from the dashboard's own toggle row as well as from the Config screen (Controller\Management\ReviewShortcutController,site-role-admin).- A submission is born
pendingand unverified, whatever the form sent — the two fields deciding whether a text is readable and whether the site vouches for it are never the author's to fill.ReviewStatusispending/published/rejected; an import is bornpublished, its platform having moderated it already. - Nothing but
publishedis ever served: every repository method a visitor reaches goes throughpublishedQueryBuilder(), so adding one never adds a way around moderation. GET|POST /review/{token}(ui_review_new) names what is reviewed through a signed token, never through its id:ReviewTokenSignersignsownerType:ownerIdwith the app secret, so a public url prints no database id and/review/book/1..nwalks nothing. Build it withui_review_url()and never withpath(), which has no id to be given any more. It resolves what is being reviewed throughFavoriteItemProviderInterface— the wishlist's own providers, rather than a contract of its own — and 404s on an unsigned token as on an id nobody claims.- The form is fetched, not printed. The section renders a
<details>fold whose panel loads the form on the first open (assets/js/review-form.js); the same route serves the form alone to an XHR and the whole page to a plain visit, so the sheet around it carries no session, no CSRF token and noSet-Cookie, and works with javascript off as a plain link. - A submitted review is notified to the site.
ReviewNotifiersends the site's ownemail-toaddress a plain-text notice in the site's locale, its result ignored — a review is stored whatever the mailer answers.ReviewAlertProvidersays on the dashboard how many are waiting. - The score goes into the same average as the clicks. Publishing a review carrying a rating calls
RatingService::record()under a voter derived from the author's e-mail (a truncated sha-256, soRatingstill holds no address of anyone); rejecting or deleting it callswithdraw().ReviewService::syncRating()does both and is called on every save, so no transition has to be remembered. A visitor who clicked the stars and left a scored review counts twice — the price of the anonymous vote, already accepted inresolveVoter(). - The "vérifié" badge is earned, never assumed.
ReviewVerifierInterface(auto-discovered, one per owner type) answers "did that address get hold of that thing?"; the shop's own implementation reads the paid orders of the address and compares item ids, never titles or slugs. No verifier for a kind meansverified: false— the badge says the site checked, not that it had no way to. Settled once insubmit()and never recomputed: an order archived years later must not un-verify a review. - A review is never rewritten here. The moderation screen edits nothing the author wrote: a local review can be published, rejected or deleted, an imported one can only be answered — removing it would hide here what stays published there, which is what L111-7-2 forbids.
- The public answer travels first.
ReviewService::reply()hands the reply toReviewReplyRegistry, which finds the platform's publisher (ReviewReplyPublisherInterface, auto-discovered by interface) and lets its exception through — a reply stored here but refused there would show an answer its author never received. A local review has no platform and is simply stored. - Displayed by the generic
collectionblock, never by a kind of its own:ReviewCollectionSourceProviderexposes the sourceui.collection.reviews, cache tagui_reviews, item templatetemplates/collection/ReviewItem.html.twig— the very cardui_reviews()draws, so a book's reviews and the site's wall never drift apart.
Wishlist
The same terms as the ratings above, for a thing a visitor puts aside: Entity\Favorite stores an
ownerType/ownerId pair and a holder, so no bundle maps a collection it never reads.
<twig:c975LUi:Favorite:Button ownerType="shop_product" ownerId="{{ product.id }}"/>
- A row is unreadable until its owner resolves it. Implement
Contract\FavoriteItemProviderInterfacein the bundle owning the thing —supports($ownerType)plusgetItems($ownerType, $ownerIds)answering the whole page at once, keyed by owner id, as the veryModel\CollectionItemthecollectionblock hands its own items over as. Auto-discovered, no tag (DependencyInjection\Compiler\FavoriteItemProviderPass). Two providers claiming oneownerTypethrows; a kind nobody implements is simply dropped from the list rather than drawn empty. - Leaving out what the visitor may no longer see — a draft, something trashed, something withdrawn from sale — is the provider's own call, being the only one that knows what "published" means for its kind of thing. A wishlist is public reading.
- Whose list it is is one opaque
holder:u<id>for an authenticated visitor, so it follows them to another browser, a 32-hex token their own browser mints on the click otherwise, kept in its ownlocalStoragestore. One column for both, under a unique constraint on(owner_type, owner_id, holder)— which is what lets a list built anonymously be handed over to the account on the next authenticated request carrying that token. - The three routes take no CSRF token, for the reason the vote's does not:
ui_favorite_page(GET /favorites, the cacheable shell),ui_favorite_toggleandui_favorite_list(both POST,no-store). A json body, anOrigin/Refererof this site and theui_favoritelimiter stand in its place.ui_favorite_listis a POST because of the token, which must not reach a url. - A refused change is read on a line of its own — a
role="status"paragraph under the heart (.favorite-status,data-ui-favorite-target="status"), carryinglabel.favorite_throttledwhen the limiter turned the change down andlabel.favorite_errorotherwise. The button is a shape cut out of a color: it carries no visible text and is in no live region, so a message written into itsaria-labelis neither seen nor announced. One bucket per address coversui_favorite_toggleandui_favorite_listalike, so/favoritesopens on that same message rather than announcing a breakdown. - A template overriding
templates/components/Favorite/Button.html.twighas to keep that element — the controller empties it on every click, and Stimulus throws on a target it cannot find. A copy taken before this release leaves the heart dead on the first click. - The button dispatches
ui-favorite:changedwith{count}, bubbling — what a navbar counter listens to. - Nothing cascades here either:
FavoriteRepository::deleteForOwner()/deleteForOwners(), on the permanent delete only.
Site search
The ai_search block asks the site's own LLM about the site's own pages (see the readme's "Site
search"). What matters when touching it:
- The index is the site read anonymously:
AiSearchIndexerfetches the titled sitemap urls over http asHealthCheck::USER_AGENT,AiSearchPageReaderkeeps the<main>text. Mark anything a page shows that must not answer questions withdata-ai-search-ignore; never feed it from the database. - No link the model writes reaches the page: it names passage numbers,
AiSiteSearchmaps them back to the index. Keep it that way, and keep the front writing withtextContentonly. - The model is never called without passages, and a question already asked against the same
indexVersionis served fromsite_ai_search_answer. - Its spend has its own
AiUsagerow (AiUsage::FEATURE_SITE_SEARCH): a provider call goes throughAbstractAiProviderClient, never a second copy of it. Only the Anthropic call carriesmax_tokens, which it requires: OpenAI's reasoning models refuse that field. POST /ai-searchtakes no CSRF token, like the vote:SameOriginRequestplus theui_ai_searchandui_ai_search_sitelimiters stand in its place. The block is not cacheable, drawn only whileai_search_enabled().- Searched in the page's locale: the block sends
app.request.localewith the question, andAiSearchControllerkeeps it only ifSiteLocales::all()holds it - the route carries no_locale. - What was asked is read in
AiSearchAnswerCrudController, and the privacy policy describes the search underai_search_configured()- the config alone, whereai_search_enabled()also needs an index. Its four config entries are inLegalPlaceholderCacheListener'sCONDITION_SLUGS, so a cached legal model follows them. - One reading of the retention:
AiSiteSearch::retentionDays()(90 when empty or not positive) is what the purge applies and whatlegal_var('ui-ai-assistant-site-retention-days')prints. Never read the raw entry elsewhere. - The same question asked twice at once loses on the unique
questionHash:AiSiteSearchcatches it and resets the manager, asAiUsageTrackerdoes on the month's row. A flush that failed closes the entity manager, so a catch withoutresetManager()still ends in a 500. - The layout already places it (
AiSearch:Dialog, opened byAiSearch:Triggeror Ctrl/Cmd+K): don't add the block to a page just to offer the search. Search markup lives once, inai_search/_search.html.twig. - The badge carries the name the site gave its assistant:
ai_search_label()readsui-ai-assistant-site-label, and the template writes the translatedlabel.ai_search_badge(Donovan (AI), 975L's own) when it is empty. AiSearchChunkRepository::currentVersion()is cached with no expiry (an empty string standing for "never indexed"), andreplaceAll()deletes it: the triggers ask it several times per page. Swap the index throughreplaceAll()only, or the search keeps reading the old version.- The index is rebuilt by
c975l:ui:ai-search:index, nightly throughUiMaintenanceTaskProvider, which purges the answers past their retention first, even on a site whose search is switched off.
Exporting
BlockDataExporter / BlockDataImporter are the shared Block/Media serialization behind every content
export carrying a block collection, containers walked recursively, medias and files included. Reuse
them rather than writing a walk of your own. A content export never carries the Form or
EmailTemplate a form block points at — seed yours on the way in with
FormBlockDependencyProviderInterface.
Do not
- Do not add a column for a block's own data — it goes in
Block::$data. - Do not put
Edit:Entityor anentity_edit_url()inside a render cache — the fragment is served to every visitor with an editor's url in it. Mark it withdata-edit-entity/data-edit-field. - Do not use
OneToManyororphanRemovalfor a blocks collection. - Do not
remove()a trashable entity from its delete action, and do not filter the flag out at each caller instead of in the repository. - Do not write a page template per entity when blocks would compose it.
- Do not cache a kind that embeds a form or reads outside data.
- Do not write a listener to empty a
{% cache %}fragment, norclear()twig.cache— it iscache.app.taggable; tag the fragment with the tag its entity already empties. - Do not wrap
render_owned_blocks()in a{% cache %}of your own — it already keeps its entry, and knows when to stay live for an editor. - Do not test
hiddenat each caller —render_block()already answers with an empty string. Filter it only where a template counts blocks or opens a cell of its own before rendering them. - Do not re-implement the block export walk.
- Do not write legal text in a template or duplicate the legal models.
- Do not add a field for outside profile urls to the contact block — contribute them through
SameAsProviderInterface. - Do not add a provider or an API key field to the
mapblock — both are site-wide settings, and an editor composing a page must not be asked to decide them again on every map. - Do not geocode at render time, and do not put
leafletback into an app's importmap — the bundle serves it, so a consuming site has nothing to install and no script host to open in its policy. - Do not bump a vendored library's number without fetching the file (or the other way round) —
bin/vendor-assets.sh <name> <version>does both, andVendorAssetsTestrefuses the mismatch. - Do not reach for
symfony/ux-mapfor a block — it builds one renderer from a compile-time DSN, which a provider picked in the back office and a key held in the database can never reach. - Do not make a singleton kind pickable.
- Do not store a block's default language in
site_translation— it stays inBlock::$data. - Do not offer a field for translation because it is a text field: declare
translatablekey by key, or a css class and an icon name end up on a language screen. - Do not write a translation from a form's POST_SUBMIT — stage it, and let
TranslationWriteListenerwrite it on the flush that saves the owner. - Do not map a relation to
Rating, and do not read a rating's scale off the request. - Do not map a relation to
Favoriteeither, and do not resolve a wishlist one row at a time —getItems()is handed the whole page's ids. - Do not return from a
FavoriteItemProviderInterfacewhat the visitor may no longer see. - Do not publish
RatingSnippetBuilder's node as a graph of its own — nest it in the node of the thing rated. - Do not read
app.flashesunguarded in a template or in an overridden{% block flashes %}: reading the bag starts a session for every anonymous visitor. Wrap it inui_can_hold_flash(), as this bundle'slayout.html.twigandFormcomponent do. - Do not call
RatingRepository::deleteForOwner()from a trash action — only from the permanent delete. - Do not build the review url with
path('ui_review_new', ...)— the route takes a signed token, which onlyui_review_url()(orReviewTokenSigner::sign()) mints. - Do not screenshot a block to illustrate it — the silhouette is drawn in
sass/_block-thumbs.scss, and a capture goes stale the day a template or the theme moves. - Do not write the thumb's five parts out by hand — render
c975LUi:Blocks:Thumb, the very markup the picker builds. - Do not add a built-in kind to this bundle from an app —
c975l:ui:block:creategenerates into the app's own namespace, which is where a one-off kind belongs.