Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
c975l-site-pages
c975l/site-bundle
claude-code
symfony
bundle
website

Use this skill when working with pages or collections in a Symfony application built on the c975L ecosystem with c975l/site-bundle — the Page entity, file-based pages, the trash and the redirects a deletion leaves behind, the block kinds this bundle adds, publish-as-replacement, and CollectionGroup/CollectionItem with their per-item detail pages. Triggers on: Page entity, page_display, page_home, page_preview, PageCrudController, twig_content, articles_slider, CollectionGroup, CollectionItem, collection block, detailPage, collectionItem, reorder, ea-index-sort, publish as replacement, duplicate page, trash, restore, site-role-admin, SiteBlockEditUrlProvider, FormEditUrl, max_input_vars, site_page, SiteDemoFixtureProvider, DemoFixtureProviderInterface, demo dataset, TwigContentTemplateChecker, templatePath, block-thumbs, ui-block-thumb, getManagementStylesheets, translate page, SiteTextProvider, c975l:translate:content, translate the whole site, management_menu_translate, TranslationController, PageTranslator, SiteLocales, enabled_locales, translation_locale, contenu, fieldset-all-languages, content_locale, PageHealthCheckPanelType, page_title, page_summary, translatable, ContentTranslator, PageLinkLocalizer, CollectionItemTranslator, render_owned_blocks, c975LSite:Page:Blocks, content zone, content holder, site_content_page, site_page_new_url, findWithBlocks, findForDisplay, PageServiceInterface, PageLocalesCacheListener, LOCALES_CACHE_TAG, PageSocialContentSource, SocialContentSourceInterface, BrowsableSocialContentSourceInterface, SocialContentStatusProviderInterface, SocialStatusCrudTrait, findSocialCandidates, isMembersOnly, members only, site-pages-members-only, isReferenced, optimistic lock, OPENED_VERSION_FIELD, page_modified_elsewhere, PageExportProvider, PageImportProvider, TutorialCatalog, TutorialCollectionSourceProvider, site.collection.tutorials, films.json, tutorial films, TutorialFilmUrlProvider, site_tutorial_film, site_tutorial_report, findOneByCollectionSource, SiteBackupPathProvider, TutorialFilmController, tutorial_film, tutorial_films, findRouteName, allPrivate, findPrivate, private films, guided-tour, TutorialCatalog::TOUR, collection_entry, shoot the films, record.mjs, films.sh, guided-project JSON, c975l:config:tutorial-account.

Install
php artisan boost:add-skill c975l/site-bundle

Save this content to: .claude/skills/c975l-site-pages/SKILL.md

# c975L SiteBundle — pages and collections

> Database-driven pages composed of UiBundle blocks, the file-based pages beside them, and the generic collections a `collection` block draws from.

**Package:** `c975l/site-bundle` · **Namespace:** `c975L\SiteBundle\` · **Twig namespace:** `@c975LSite` · **Translation domain:** `site`

**Key source paths** (relative to the package root):
`src/Entity/Page.php`, `src/Entity/CollectionGroup.php`, `src/Entity/CollectionItem.php`, `src/Controller/PageController.php`, `src/Controller/Management/`, `src/Service/CollectionItemSourceProvider.php`, `src/Service/TutorialCatalog.php`, `src/Service/TutorialCollectionSourceProvider.php`, `src/Controller/TutorialController.php`, `src/Controller/Management/TutorialFilmController.php`, `src/Service/CollectionItemTranslator.php`, `src/Service/PagePublicUrlResolver.php`, `src/Service/PageTranslator.php`, `src/Twig/PageExtension.php`, `src/Twig/PageTranslationExtension.php`, `src/Twig/CollectionItemContext.php`, `src/Form/Block/`, `templates/blocks/`, `templates/pages/`, `templates/components/Page/Blocks.html.twig`, `config/services.yaml`

**Related skills:** `c975l-site-layout`, `c975l-site-menus`, `c975l-site-seo`, `c975l-site-assets` in this same package. The block system itself, the media library and the legal models are in `c975l/core-bundle`.

## Two kinds of pages

| Kind | Where | Served at |
| --- | --- | --- |
| File-based | `templates/pages/*.html.twig` in the app | `/pages/{slug}` (`page_display`) |
| Database | the `Page` entity, composed in EasyAdmin | `/pages/{slug}`, or `/` for the `home` slug (`page_home`) |

**File-based pages are not in the sitemap** — a template carries no `lastmod`, no priority and no
publication state. Create the page in the database if it has to be crawled.

Two conventions sit beside them: `templates/pages/redirected/{slug}.html.twig` redirects to the slug
written inside the file, and `templates/pages/deleted/{slug}.html.twig` throws a 410.
**Database-managed redirects are ConfigBundle's** (`Redirect` entity, its own CRUD and health check) —
they answer before the router and serve any bundle's urls, not only pages.

## The Page entity

Title, unique slug, `summarySocialNetwork`, published state, position, blocks, timestamps and author,
plus the sitemap fields (indexable, change frequency, priority).

- **Unpublishing unreferences.** `isIndexable` follows `isPublished` down, whatever unpublished the
  page — form, trash, replacement, duplication or import (`Page::unreferenceWhenUnpublished()`, a
  `PreFlush` callback). Publishing again does **not** put it back: referencing a page is a deliberate
  call. The page answers 404 the moment it is unpublished.
- **Members only** (`isMembersOnly`, a column). An anonymous visitor is sent to the login form
  (`PageController::gate()` throws `AccessDeniedException`), a collection item rendered through such a
  detail page too — it replaces an `access_control` rule on `^/pages/...`. While ticked the page is left
  out of the sitemap, `robots` and social posts through `Page::isReferenced()`, `isIndexable` itself
  untouched, so unticking references it again. `site-pages-members-only` ticks it on a page created in
  the back office; `home` is never members only (forced back in the `PreFlush`).
- **Display the page title** is stored in `Page::$options`, one JSON column for the page's benign
  display options — the same reasoning as `Block::$data`, so adding an option is a code change with no
  migration for every app running this bundle. Read and write through named accessors
  (`isTitleDisplayed()` / `setIsTitleDisplayed()`), never as raw string keys. Anything the database has
  to filter, sort or join on (`slug`, `isPublished`, `isIndexable`) stays a real column.
- **`version` is Doctrine's optimistic lock** (`#[ORM\Version]`). The edit form carries the version it
  was opened on (`PageCrudController::OPENED_VERSION_FIELD`), and a save made after the page was saved
  elsewhere is refused with the input kept on screen. The edit screen's title names the page and the
  language written in, so several open tabs can be told apart.
- **An exported page carries its translations**, always under a `translations` key, empty when it has
  none: the import updates an existing page in place and replaces its translations with the zip's. A zip
  without the key leaves them untouched.
- Deleting permanently leaves gone rows behind on its own: one for the page's url, one per redirect
  that pointed at it.
- **The trash's two row actions are GETs**, so each carries a CSRF token in its url
  (`PageCrudController::RESTORE_CSRF_TOKEN`, `PageCrudController::DELETE_PERMANENTLY_CSRF_TOKEN`). An
  override re-declaring either one builds that url too — `linkToCrudAction()` on its own gets the
  action refused and sent back to the trash.
- **`restore` and `deletePermanently` are `site-role-admin`**, where every other page action is
  `site-role-editor`: deleting only moves a page to the trash, which an editor may do, but pulling one
  back out or removing it for good is the bar those two methods state themselves — a button leading to
  its own 403 is a button not to draw.
- **The three table exports (`exportSql`, `exportCsv`, `exportJson`) are `site-role-admin` too**, the
  same bar their own methods state — an editor is shown no button leading to their 403.

A public page's blocks are rendered as **one cache entry** (`render_owned_blocks(page)` in
`page.html.twig`, emptied by UiBundle's `OwnedBlocksCacheListener`): `PageServiceInterface::findForDisplay()`
reads the row alone, the blocks, medias and slots being read only on a miss. The renders no entry stands in
for — the preview, a collection's detail page — go through `findWithBlocks()`, which reads them up front; an
app overriding `PageServiceInterface` implements both.

`PageController::preview()` opts out of the block render cache entirely — an editor's preview must show
what was just saved, and its render is not the public one.

**A page is one page in every language** — one structure, one set of blocks, one row. There is no
locale column and no second row: the translations sit beside the page (`PageTranslator`, over
UiBundle's `ContentTranslator`), so moving, adding or removing a block moves it in every language at
once. The languages a site offers are declared in `config/packages/translation.yaml`
(`framework.enabled_locales`, read through `SiteLocales`), never in a config entry: Symfony itself
reads that list.

A page is translated **on its own edit screen**, opened on another language: the same fields in the
same places, filled with what that language says — or the source text between brackets where it says
nothing yet, which is both the thing to translate and the mark of what is left to do. A field handed
back still holding its brackets is stored as nothing. The language selector sits at the top of the
screen and writes `?contenu=<locale>` (`PageCrudController::CONTENT_LOCALE_PARAM`), which the
**Translate** action of the index links to; `configureFields()` then yields the page's own two texts
and its blocks alone, unmapped and through `BlockType`'s `translation_locale`, so nothing written
there can reach the text the site was written in. The kind, the animation, the medias, the "+" and the
bin are all left off: a page is composed once, in the language it was written in.

What a language cannot change on its own — the slug, the publication and indexation flags, the change
frequency and priority, the sharing image — is repeated on that screen in a group of its own
(`.fieldset-all-languages`) and **mapped**: written from the English screen, those fields are written for
every language at once. Everything from the title down belongs to the language on screen, its own
"Health check" tab included (`PageHealthCheckPanelType`'s `content_locale`, the rows of that language's
url). UiBundle's "edit this block" button on the site opens the screen of the language the page is being
read in (`SiteBlockEditUrlProvider`, reading the `_locale` route attribute).

Reading one translated field of many pages goes through `PageTranslator::value($page, $locale, $field)`,
which reads the cache `preload()` fills; `PageTranslator::all($page)` goes straight to base whatever was
read ahead, so it costs a query per page and per language in a walk over the whole site.

**The title is what makes a page exist in a language.** `PageTranslator::translatedLocales()` reads it
and nothing else: a page whose blocks were translated while its title stayed in the writing language is
not published under `/{lang}/pages/{slug}` (which answers 404), is named by no `hreflang` group, and is
linked to by no menu — a French `<title>` being the first thing a result page shows. The answer is cached
per page under `PageTranslator::LOCALES_CACHE_TAG`, emptied by `PageLocalesCacheListener` whenever a page or
one of its translations is written.

A **menu** keeps a screen of its own (`management_menu_translate`, `TranslationController::menu()`) —
it is a list of labels with no page to show them in. See `c975l-site-seo` for the urls and the
`hreflang` groups.

**A whole page is one form**, every block and every media in a single POST, so it is what reaches PHP's
`max_input_vars` (1000 by default) first. Past that limit PHP drops the rest of the body silently and
the missing blocks read as "the editor removed them", so such a submission is refused whole
(`SubmissionIntegrity`, UiBundle). A site stacking many blocks raises `max_input_vars` in `php.ini`.

## Block kinds this bundle adds

| Kind | Category | What it does |
| --- | --- | --- |
| `twig_content` | twig | includes an existing Twig template by its path (`templatePath`) — a general-purpose "drop this template in here". **Only the app's own templates, under `templates/`**: an installed bundle's templates are refused by the form and again at render time (`TwigContentTemplateChecker`, Twig function `site_twig_content_allowed()`), the path being typed in the back office |
| `articles_slider` | navigation | picks another page and renders its `article` blocks that carry a media as a slider |
| `menu_link` | navigation | a link to a page or a contributed route; restricted to the menu contexts |
| `menu_group` | navigation | a chromeless wrapper grouping several items on a line of a menu |

The last two belong to menus — see the `c975l-site-menus` skill. `articles_slider` uses the
`site_page(id)` Twig function to eager-load the target page with its blocks and medias.

Every kind also carries a **silhouette**, a CSS-drawn miniature of its layout shown by the visual block
picker (`sass/block-thumbs.scss` → `public/css/block-thumbs.min.css`, contributed to the back office by
`StylesheetProvider::getManagementStylesheets()` under the `ui.management_stylesheet` tag). A kind
without one shows the bare default frame; `StylesheetProviderTest` reads the pickable kinds off
`config/services.yaml` and fails on any that is undrawn.

Hovering a block as an editor raises UiBundle's Edit button, and `Management\SiteBlockEditUrlProvider`
answers where it goes. Two kinds are edited somewhere other than the Page form carrying them: a
`legal_model` on its wording screen (`LegalModelEditUrl`) and a `form` on the Form's own, where its
fields are (`FormEditUrl`). Anything else — and either of those pointing at something that no longer
exists, which would 404 — opens the Page form with that row unfolded (`BlockFocusUrl`).

## Publish as replacement

Any non-deleted page's edit screen lists every other page as a replacement target: picking one archives
the target's slug, moves it to the trash and swaps the current page in. The usual way to prepare one is
the **Duplicate** action, which builds an unpublished copy of the whole block tree, medias included.

**There is deliberately no page-template mechanism.** A page's arrangement is composed in the admin,
block by block, never derived from a stored arrangement — a "template" could only be a snapshot of
example content with no relation kept afterwards. Use Duplicate.

## Collections

A `CollectionGroup` ("Projects") is a named, slugified container of `CollectionItem` rows — one table
backs every collection of a site, what separates two collections being only which group an item belongs
to. Two CRUDs on purpose: **Collections** creates and renames the groups, and its *Items* action — on
the index and on the edit screen alike — opens `CollectionItemCrudController` filtered to that one group
(`?collectionGroup=<id>`), so a typo can never spawn an unrelated collection. The edit screen also lists
the group's own items above the form (`configureResponseParameters()` hands them to
`collection_crud_edit.html.twig`, capped at 100 with the total alongside), each row linking to that
item's edit; reordering and deleting stay on the items index. An item's slug is unique **within its
collection only**, unlike `Page::$slug`.

`CollectionItemSourceProvider` exposes every group to UiBundle's `collection` block, keyed
`site.collection.{slug}` — **creating a collection is enough to make it pickable, no code change**.
Each source declares its own cache tag, invalidated when an item or the group is saved.

An item's title and description are translatable through `CollectionItemTranslator` (owner
`site_collection_item`): the items index offers a "Translate" action on a site declaring more than one
language, its language screen carrying those two fields alone, and the cards and the detail page read them
in the language being read. Image, link, slug and position are the same in every language.

The items index reorders by drag-and-drop through **UiBundle's `ea-index-sort.js`**, which SiteBundle no
longer duplicates: `collection_item_crud_index.html.twig` opts in by declaring `data-reorder-url`,
`data-reorder-group` and `data-reorder-token` on each row, and `CollectionItemCrudController::reorder()`
answers `{positions: {id: position}}` — what it actually persisted, the submitted ids being re-checked
against the submitted group rather than trusted.

The image an item names is health-checked on the server by `CollectionFilesHealthCheckProvider` (kind
`files-site`, see `c975l-site-seo`) — a file gone from `public/` is an error row, not a silent hole.

### Item detail pages

Each item gets a per-item url without a row of its own. Two editorial steps, once per collection:

1. create a `Page` for the detail view (any slug) and give it the blocks it needs — a `twig_content`
   block for a custom template, native kinds for a simpler layout;
2. on the **other** page, the one carrying the `collection` block, fill that block's `detailPage` field
   with the detail page's slug.

From then on `/pages/{page}/{itemSlug}` calls the source's `detail($itemSlug)`; a non-null result
renders the detail page's blocks with a `collectionItem` Twig global exposing it for that one render
(`CollectionItemContext`). A `twig_content` block reads it as
`{% include templatePath with collectionItem.get() %}`, its `templatePath` being one of the app's own
templates. A null result falls through to a 404. **Nothing
is persisted per item.**

**The language is confirmed before the detail renders.** `PageController::display()` runs its gate — deleted,
unpublished, untranslated, asked-in-another-language — on the parent page *before* resolving the item, because
resolving it renders its blocks and their internal links, read in the language of the response (UiBundle's
`BlockExtension::localizeLinks()`, applied outside the block cache): nothing is rendered for a page the gate
turns away or a language it redirects from. `PageController::preview()` deliberately
skips the gate, a preview being the screen for a page the public gate turns away, and caches nothing.

### Tutorial films

`TutorialCollectionSourceProvider` adds one more source, `site.collection.tutorials`: the films of the
guided projects, read by `TutorialCatalog` from `public/medias/films/<locale>/films.json` (one entry per
guided project slug: `version`, `narrated`, `starts`, optional `shotAt` and `shotOn`) beside each film's `.webm`, `.vtt`
and `.jpg`. An entry without `version` is ignored; a locale with no film of its own falls back film by
film to the site's default locale. The page showing them is an ordinary page holding a `collection` block
on that source, each card drawn by `@c975LSite/collection/TutorialItem.html.twig` and the `tutorial`
Stimulus controller, the template also printing the film's `VideoObject` JSON-LD (dated by `shotAt`).
The dashboard's guided tour, filmed under `TutorialCatalog::TOUR` (`guided-tour`), leads `all()` once
in the manifest, as a project without steps; each item carries its film's slug for a `collection_entry` block.

`TutorialController` adds two routes around that page, found through
`PageRepository::findOneByCollectionSource()`: `site_tutorial_film` (`/tutorials/film/{slug}`, what
`TutorialFilmUrlProvider` links a filmed guided project with from the dashboard) and
`site_tutorial_report` (`/tutorials/{slug}/report/{step}`, the page holding the `contact` form with its
subject prefilled, step 0 being the whole film). `SiteBackupPathProvider` declares `public/medias/films`
to ConfigBundle's backup.

Films only the back office shows go to `private/medias/films/<locale>/` instead (same layout, all three
files expected), read by `TutorialCatalog::findPrivate()` and listed by `TutorialCatalog::allPrivate()`
for the parcours the reader may follow. The `tutorial_films` admin route (`/management/tutorial-films`,
`TutorialFilmController::index()`, linked by `MenuProvider::getLinks()` in the dashboard's "Advanced" submenu) shows them with the very card and dialog of the public page
(`items(..., public: false)`: no report link, no JSON-LD), styled by `management-tutorials.min.css` (linked
by the page's own template, not by `StylesheetProvider`) and the `tutorial` controller registered in
`controllers-admin.js`; `TutorialFilmUrlProvider::getFilmUrl()` links a private film to its anchor there -
only for a reader passing the back office's gate and `GuidedProjectBuilder::isGranted()`, and unless the
film was found in the site's language while a public one speaks the reader's. Their files come from the
`tutorial_film` admin route (`/management/tutorial-film/{locale}/{slug}.{webm|vtt|jpg}`): served to
whoever passes `GuidedProjectBuilder::isGranted()` for the project, revalidated on each reading, a 404
otherwise or when the locale in the url has no film of its own. Both are named after the dashboard
carrying them, asked of EasyAdmin's `AdminRouteGenerator::findRouteName()` (aliased to its interface in
`services.yaml`), never written `management_...`; no dashboard carrying the page, or a reader it would not
show the film to, leaves the project on the public film or the ecosystem's link. `private/medias/films` is
backed up too.

Shooting the films is no SiteBundle code: the c975L camera is a private Node + Playwright tool. The README's
"How the c975L films are shot" gives its mechanism and a bare-bones `films.sh` + `record.mjs` to adapt. A
shooter only needs the local `symfony serve` URL, `c975l:config:tutorial-account --as=<level>` (the project's own level, no `role_hierarchy` being shipped; JSON
`email`/`password`, `--close` after) and `/management/guided-project/{slug}` (steps as JSON in the
`Accept-Language` asked, if `framework.enabled_locales` declares it, the default locale otherwise: `label`, `description`, `narration`, `url`, `highlight`). It writes one `starts`
entry per step, or the steps go untimed.

## Content zones

A screen the app's own controller renders (a form, a preview, a list) takes its prose from a Page:
`<twig:c975LSite:Page:Blocks slug="shortcut-preview"/>` renders that Page's blocks through
`render_owned_blocks()` - same cache, locale and editor overlay as a page display - **published or
not**. The Page is a content holder, left unpublished so `/pages/{slug}` stays 404 and the sitemap
leaves it out. No page yet: nothing for a visitor, a link to `PageCrudController`'s *new* action with
`?slug=` prefilled (`createEntity()`) for `site-role-editor`. A trashed page renders nothing for anyone.
Convention, no flag: slug named after the screen, title starting with `Écran - `, *Published* filter
on the index.

## Commands

```bash
php bin/console c975l:site:pages:import-defaults        # home + the legal pages if absent, legal descriptions realigned
php bin/console c975l:site:collection-item:import --group=<group> --json-file=<path>
php bin/console c975l:site:create                       # one-shot wizard bootstrapping a new site
php bin/console c975l:translate:content --locale=en --dry-run  # UiBundle's; SiteTextProvider hands it the pages, menus and collection items
```

## Twig functions

`site_page(id)`, `site_content_page(slug)`, `site_page_new_url(slug)`, `site_legal_pages(models)`, `site_page_for_form_block(formName)`,
`page_health_check(page)`, `page_title(page)`, `page_summary(page)`, `page_alternates(page)`. All
declared with `#[AsTwigFunction]` on the method backing them — a site overriding one decorates the
service and carries the attribute over.

**Read a page's title through `page_title(page)`, not `page.title`**, wherever a visitor sees it: the
raw column is the writing language's, whatever language the page is being read in.

## Demo dataset

`SiteDemoFixtureProvider` (UiBundle's `DemoFixtureProviderInterface`) hands a demo site three published pages with
their blocks and a collection of three items, read by a `collection` block on `nos-services` - loading it is that
site's own business, this bundle shipping no command that writes to a database. The content is 975L's own, each
item leading to one of its real sites (an outside URL); a site wanting another demo decorates the provider. The
second pass lays a navbar only where none exists. The pages carry written-down creation dates and are left out of
the index; their blocks ride the cascade, while each `CollectionItem` is handed over on its own, nothing cascading
off a `CollectionGroup`. `home` holds `hero`, `feature_bar`, `section_features`, `process_steps`, `faq`,
`cta_band`; `nos-services` holds `text_section`, `section_features`, `process_steps`, `collection`, `cta_band`;
`notre-histoire` holds `text_section`, `feature_bar`, `process_steps`, `text_section`, `cta_band`. The hero's buttons
and the three `cta_band` are linked as `page:ID` in the second pass (`getLinkedDemoFixtures()`), never as a raw path.
The `collection` block uses the `portfolio` variant; an item's picture is the site's `keyed_images` entry
`site/<slug>` when declared, a picture of the `images` pool otherwise.

## Social publication

With SocialBundle installed, `PageSocialContentSource` (UiBundle's `SocialContentSourceInterface`, source
type `page`) hands its automatic publication the oldest page not posted yet, once, with the page's own
sharing image or as text without one. Unreferenced pages (`isReferenced()`) and pages holding a `legal_model` block are never
offered, and nothing goes out while `site-url` is empty. What was posted where is SocialBundle's to record.
The candidates come from `PageRepository::findSocialCandidates()`, which applies that rule in SQL (oldest
first, then by id) without loading blocks; `getContent()` re-checks a single page in PHP (`isPostable()`).
The source is also browsable (`BrowsableSocialContentSourceInterface`): `findContents()` lists the free
pages latest first, a page without a public url never taking a place. With SocialBundle, the pages' index
shows a "Réseaux sociaux" badge (UiBundle's `SocialStatusCrudTrait`) read from `SocialContentStatusProviderInterface` for the rows of the page shown, never on the trash;
the badge's date format is the translation `label.page_social_date_format`.

## Do not

- **Do not change which pages may be posted in one place only** — `findSocialCandidates()` (SQL) and
  `PageSocialContentSource::isPostable()` (PHP) hold the same rule.
- **Do not add a menu to the demo dataset** — a site's navigation is its own content, and a reload
  would put back what it thinks belongs there.
- **Do not create a file-based page for content that must be crawled** — it is absent from the sitemap
  by construction.
- **Do not write a redirect mechanism.** ConfigBundle's `Redirect` rows answer before the router, for
  every bundle's urls.
- **Do not add a column to `Page` for a display option.** It goes in `Page::$options`, behind a named
  accessor.
- **Do not re-publish a page expecting it to be indexed again** — `isIndexable` has to be checked back
  deliberately.
- **Do not build a page-template feature.** Duplicate is the answer.
- **Do not set `isIndexable` to false to hide a members' page.** Read `isReferenced()`; the flag is kept
  for the day the page is opened again.
- **Do not publish a content holder, nor hardcode a screen's prose in the app's template.** A
  `<twig:c975LSite:Page:Blocks/>` zone reads the unpublished page; publishing it opens a second url.
- **Do not add a free-text collection field on an item.** The group is picked by the screen it is
  opened from.
- **Do not create a `Page` per collection item.** One detail page serves the whole collection.
- **Do not add a block kind without drawing its silhouette** — the picker then shows it as a bare
  frame, which is what the picker exists to avoid.
- **Do not cache a preview render.**
- **Do not let a media purge walk `public/medias/films`** — no row points at the tutorial films, so a
  purge of unreferenced files would delete them all.
- **Do not add a locale column to `Page`, nor duplicate a page per language.** The translations live
  beside the row; the structure is edited once.
- **Do not render a collection item detail before the gate** — a deleted or unpublished page, or a language
  the gate redirects from, would be rendered for nothing.
- **Do not empty the block cache when a page enters or leaves a language** — no cached html holds a localised
  link, `BlockExtension::localizeLinks()` running outside the cache on every request.
- **Do not drop `OPENED_VERSION_FIELD` from an overridden edit form** — without it the lock never fires
  and the save made last silently overwrites the other tab.
- **Do not replace an entity's title in memory to translate it** — Doctrine writes it back on the next
  flush. Read it through `page_title()` / `PageTranslator`.

c975L SiteBundle — pages and collections

Database-driven pages composed of UiBundle blocks, the file-based pages beside them, and the generic collections a collection block draws from.

Package: c975l/site-bundle · Namespace: c975L\SiteBundle\ · Twig namespace: @c975LSite · Translation domain: site

Key source paths (relative to the package root): src/Entity/Page.php, src/Entity/CollectionGroup.php, src/Entity/CollectionItem.php, src/Controller/PageController.php, src/Controller/Management/, src/Service/CollectionItemSourceProvider.php, src/Service/TutorialCatalog.php, src/Service/TutorialCollectionSourceProvider.php, src/Controller/TutorialController.php, src/Controller/Management/TutorialFilmController.php, src/Service/CollectionItemTranslator.php, src/Service/PagePublicUrlResolver.php, src/Service/PageTranslator.php, src/Twig/PageExtension.php, src/Twig/PageTranslationExtension.php, src/Twig/CollectionItemContext.php, src/Form/Block/, templates/blocks/, templates/pages/, templates/components/Page/Blocks.html.twig, config/services.yaml

Related skills: c975l-site-layout, c975l-site-menus, c975l-site-seo, c975l-site-assets in this same package. The block system itself, the media library and the legal models are in c975l/core-bundle.

Two kinds of pages

Kind Where Served at
File-based templates/pages/*.html.twig in the app /pages/{slug} (page_display)
Database the Page entity, composed in EasyAdmin /pages/{slug}, or / for the home slug (page_home)

File-based pages are not in the sitemap — a template carries no lastmod, no priority and no publication state. Create the page in the database if it has to be crawled.

Two conventions sit beside them: templates/pages/redirected/{slug}.html.twig redirects to the slug written inside the file, and templates/pages/deleted/{slug}.html.twig throws a 410. Database-managed redirects are ConfigBundle's (Redirect entity, its own CRUD and health check) — they answer before the router and serve any bundle's urls, not only pages.

The Page entity

Title, unique slug, summarySocialNetwork, published state, position, blocks, timestamps and author, plus the sitemap fields (indexable, change frequency, priority).

  • Unpublishing unreferences. isIndexable follows isPublished down, whatever unpublished the page — form, trash, replacement, duplication or import (Page::unreferenceWhenUnpublished(), a PreFlush callback). Publishing again does not put it back: referencing a page is a deliberate call. The page answers 404 the moment it is unpublished.
  • Members only (isMembersOnly, a column). An anonymous visitor is sent to the login form (PageController::gate() throws AccessDeniedException), a collection item rendered through such a detail page too — it replaces an access_control rule on ^/pages/.... While ticked the page is left out of the sitemap, robots and social posts through Page::isReferenced(), isIndexable itself untouched, so unticking references it again. site-pages-members-only ticks it on a page created in the back office; home is never members only (forced back in the PreFlush).
  • Display the page title is stored in Page::$options, one JSON column for the page's benign display options — the same reasoning as Block::$data, so adding an option is a code change with no migration for every app running this bundle. Read and write through named accessors (isTitleDisplayed() / setIsTitleDisplayed()), never as raw string keys. Anything the database has to filter, sort or join on (slug, isPublished, isIndexable) stays a real column.
  • version is Doctrine's optimistic lock (#[ORM\Version]). The edit form carries the version it was opened on (PageCrudController::OPENED_VERSION_FIELD), and a save made after the page was saved elsewhere is refused with the input kept on screen. The edit screen's title names the page and the language written in, so several open tabs can be told apart.
  • An exported page carries its translations, always under a translations key, empty when it has none: the import updates an existing page in place and replaces its translations with the zip's. A zip without the key leaves them untouched.
  • Deleting permanently leaves gone rows behind on its own: one for the page's url, one per redirect that pointed at it.
  • The trash's two row actions are GETs, so each carries a CSRF token in its url (PageCrudController::RESTORE_CSRF_TOKEN, PageCrudController::DELETE_PERMANENTLY_CSRF_TOKEN). An override re-declaring either one builds that url too — linkToCrudAction() on its own gets the action refused and sent back to the trash.
  • restore and deletePermanently are site-role-admin, where every other page action is site-role-editor: deleting only moves a page to the trash, which an editor may do, but pulling one back out or removing it for good is the bar those two methods state themselves — a button leading to its own 403 is a button not to draw.
  • The three table exports (exportSql, exportCsv, exportJson) are site-role-admin too, the same bar their own methods state — an editor is shown no button leading to their 403.

A public page's blocks are rendered as one cache entry (render_owned_blocks(page) in page.html.twig, emptied by UiBundle's OwnedBlocksCacheListener): PageServiceInterface::findForDisplay() reads the row alone, the blocks, medias and slots being read only on a miss. The renders no entry stands in for — the preview, a collection's detail page — go through findWithBlocks(), which reads them up front; an app overriding PageServiceInterface implements both.

PageController::preview() opts out of the block render cache entirely — an editor's preview must show what was just saved, and its render is not the public one.

A page is one page in every language — one structure, one set of blocks, one row. There is no locale column and no second row: the translations sit beside the page (PageTranslator, over UiBundle's ContentTranslator), so moving, adding or removing a block moves it in every language at once. The languages a site offers are declared in config/packages/translation.yaml (framework.enabled_locales, read through SiteLocales), never in a config entry: Symfony itself reads that list.

A page is translated on its own edit screen, opened on another language: the same fields in the same places, filled with what that language says — or the source text between brackets where it says nothing yet, which is both the thing to translate and the mark of what is left to do. A field handed back still holding its brackets is stored as nothing. The language selector sits at the top of the screen and writes ?contenu=<locale> (PageCrudController::CONTENT_LOCALE_PARAM), which the Translate action of the index links to; configureFields() then yields the page's own two texts and its blocks alone, unmapped and through BlockType's translation_locale, so nothing written there can reach the text the site was written in. The kind, the animation, the medias, the "+" and the bin are all left off: a page is composed once, in the language it was written in.

What a language cannot change on its own — the slug, the publication and indexation flags, the change frequency and priority, the sharing image — is repeated on that screen in a group of its own (.fieldset-all-languages) and mapped: written from the English screen, those fields are written for every language at once. Everything from the title down belongs to the language on screen, its own "Health check" tab included (PageHealthCheckPanelType's content_locale, the rows of that language's url). UiBundle's "edit this block" button on the site opens the screen of the language the page is being read in (SiteBlockEditUrlProvider, reading the _locale route attribute).

Reading one translated field of many pages goes through PageTranslator::value($page, $locale, $field), which reads the cache preload() fills; PageTranslator::all($page) goes straight to base whatever was read ahead, so it costs a query per page and per language in a walk over the whole site.

The title is what makes a page exist in a language. PageTranslator::translatedLocales() reads it and nothing else: a page whose blocks were translated while its title stayed in the writing language is not published under /{lang}/pages/{slug} (which answers 404), is named by no hreflang group, and is linked to by no menu — a French <title> being the first thing a result page shows. The answer is cached per page under PageTranslator::LOCALES_CACHE_TAG, emptied by PageLocalesCacheListener whenever a page or one of its translations is written.

A menu keeps a screen of its own (management_menu_translate, TranslationController::menu()) — it is a list of labels with no page to show them in. See c975l-site-seo for the urls and the hreflang groups.

A whole page is one form, every block and every media in a single POST, so it is what reaches PHP's max_input_vars (1000 by default) first. Past that limit PHP drops the rest of the body silently and the missing blocks read as "the editor removed them", so such a submission is refused whole (SubmissionIntegrity, UiBundle). A site stacking many blocks raises max_input_vars in php.ini.

Block kinds this bundle adds

Kind Category What it does
twig_content twig includes an existing Twig template by its path (templatePath) — a general-purpose "drop this template in here". Only the app's own templates, under templates/: an installed bundle's templates are refused by the form and again at render time (TwigContentTemplateChecker, Twig function site_twig_content_allowed()), the path being typed in the back office
articles_slider navigation picks another page and renders its article blocks that carry a media as a slider
menu_link navigation a link to a page or a contributed route; restricted to the menu contexts
menu_group navigation a chromeless wrapper grouping several items on a line of a menu

The last two belong to menus — see the c975l-site-menus skill. articles_slider uses the site_page(id) Twig function to eager-load the target page with its blocks and medias.

Every kind also carries a silhouette, a CSS-drawn miniature of its layout shown by the visual block picker (sass/block-thumbs.scss → public/css/block-thumbs.min.css, contributed to the back office by StylesheetProvider::getManagementStylesheets() under the ui.management_stylesheet tag). A kind without one shows the bare default frame; StylesheetProviderTest reads the pickable kinds off config/services.yaml and fails on any that is undrawn.

Hovering a block as an editor raises UiBundle's Edit button, and Management\SiteBlockEditUrlProvider answers where it goes. Two kinds are edited somewhere other than the Page form carrying them: a legal_model on its wording screen (LegalModelEditUrl) and a form on the Form's own, where its fields are (FormEditUrl). Anything else — and either of those pointing at something that no longer exists, which would 404 — opens the Page form with that row unfolded (BlockFocusUrl).

Publish as replacement

Any non-deleted page's edit screen lists every other page as a replacement target: picking one archives the target's slug, moves it to the trash and swaps the current page in. The usual way to prepare one is the Duplicate action, which builds an unpublished copy of the whole block tree, medias included.

There is deliberately no page-template mechanism. A page's arrangement is composed in the admin, block by block, never derived from a stored arrangement — a "template" could only be a snapshot of example content with no relation kept afterwards. Use Duplicate.

Collections

A CollectionGroup ("Projects") is a named, slugified container of CollectionItem rows — one table backs every collection of a site, what separates two collections being only which group an item belongs to. Two CRUDs on purpose: Collections creates and renames the groups, and its Items action — on the index and on the edit screen alike — opens CollectionItemCrudController filtered to that one group (?collectionGroup=<id>), so a typo can never spawn an unrelated collection. The edit screen also lists the group's own items above the form (configureResponseParameters() hands them to collection_crud_edit.html.twig, capped at 100 with the total alongside), each row linking to that item's edit; reordering and deleting stay on the items index. An item's slug is unique within its collection only, unlike Page::$slug.

CollectionItemSourceProvider exposes every group to UiBundle's collection block, keyed site.collection.{slug} — creating a collection is enough to make it pickable, no code change. Each source declares its own cache tag, invalidated when an item or the group is saved.

An item's title and description are translatable through CollectionItemTranslator (owner site_collection_item): the items index offers a "Translate" action on a site declaring more than one language, its language screen carrying those two fields alone, and the cards and the detail page read them in the language being read. Image, link, slug and position are the same in every language.

The items index reorders by drag-and-drop through UiBundle's ea-index-sort.js, which SiteBundle no longer duplicates: collection_item_crud_index.html.twig opts in by declaring data-reorder-url, data-reorder-group and data-reorder-token on each row, and CollectionItemCrudController::reorder() answers {positions: {id: position}} — what it actually persisted, the submitted ids being re-checked against the submitted group rather than trusted.

The image an item names is health-checked on the server by CollectionFilesHealthCheckProvider (kind files-site, see c975l-site-seo) — a file gone from public/ is an error row, not a silent hole.

Item detail pages

Each item gets a per-item url without a row of its own. Two editorial steps, once per collection:

  1. create a Page for the detail view (any slug) and give it the blocks it needs — a twig_content block for a custom template, native kinds for a simpler layout;
  2. on the other page, the one carrying the collection block, fill that block's detailPage field with the detail page's slug.

From then on /pages/{page}/{itemSlug} calls the source's detail($itemSlug); a non-null result renders the detail page's blocks with a collectionItem Twig global exposing it for that one render (CollectionItemContext). A twig_content block reads it as {% include templatePath with collectionItem.get() %}, its templatePath being one of the app's own templates. A null result falls through to a 404. Nothing is persisted per item.

The language is confirmed before the detail renders. PageController::display() runs its gate — deleted, unpublished, untranslated, asked-in-another-language — on the parent page before resolving the item, because resolving it renders its blocks and their internal links, read in the language of the response (UiBundle's BlockExtension::localizeLinks(), applied outside the block cache): nothing is rendered for a page the gate turns away or a language it redirects from. PageController::preview() deliberately skips the gate, a preview being the screen for a page the public gate turns away, and caches nothing.

Tutorial films

TutorialCollectionSourceProvider adds one more source, site.collection.tutorials: the films of the guided projects, read by TutorialCatalog from public/medias/films/<locale>/films.json (one entry per guided project slug: version, narrated, starts, optional shotAt and shotOn) beside each film's .webm, .vtt and .jpg. An entry without version is ignored; a locale with no film of its own falls back film by film to the site's default locale. The page showing them is an ordinary page holding a collection block on that source, each card drawn by @c975LSite/collection/TutorialItem.html.twig and the tutorial Stimulus controller, the template also printing the film's VideoObject JSON-LD (dated by shotAt). The dashboard's guided tour, filmed under TutorialCatalog::TOUR (guided-tour), leads all() once in the manifest, as a project without steps; each item carries its film's slug for a collection_entry block.

TutorialController adds two routes around that page, found through PageRepository::findOneByCollectionSource(): site_tutorial_film (/tutorials/film/{slug}, what TutorialFilmUrlProvider links a filmed guided project with from the dashboard) and site_tutorial_report (/tutorials/{slug}/report/{step}, the page holding the contact form with its subject prefilled, step 0 being the whole film). SiteBackupPathProvider declares public/medias/films to ConfigBundle's backup.

Films only the back office shows go to private/medias/films/<locale>/ instead (same layout, all three files expected), read by TutorialCatalog::findPrivate() and listed by TutorialCatalog::allPrivate() for the parcours the reader may follow. The tutorial_films admin route (/management/tutorial-films, TutorialFilmController::index(), linked by MenuProvider::getLinks() in the dashboard's "Advanced" submenu) shows them with the very card and dialog of the public page (items(..., public: false): no report link, no JSON-LD), styled by management-tutorials.min.css (linked by the page's own template, not by StylesheetProvider) and the tutorial controller registered in controllers-admin.js; TutorialFilmUrlProvider::getFilmUrl() links a private film to its anchor there - only for a reader passing the back office's gate and GuidedProjectBuilder::isGranted(), and unless the film was found in the site's language while a public one speaks the reader's. Their files come from the tutorial_film admin route (/management/tutorial-film/{locale}/{slug}.{webm|vtt|jpg}): served to whoever passes GuidedProjectBuilder::isGranted() for the project, revalidated on each reading, a 404 otherwise or when the locale in the url has no film of its own. Both are named after the dashboard carrying them, asked of EasyAdmin's AdminRouteGenerator::findRouteName() (aliased to its interface in services.yaml), never written management_...; no dashboard carrying the page, or a reader it would not show the film to, leaves the project on the public film or the ecosystem's link. private/medias/films is backed up too.

Shooting the films is no SiteBundle code: the c975L camera is a private Node + Playwright tool. The README's "How the c975L films are shot" gives its mechanism and a bare-bones films.sh + record.mjs to adapt. A shooter only needs the local symfony serve URL, c975l:config:tutorial-account --as=<level> (the project's own level, no role_hierarchy being shipped; JSON email/password, --close after) and /management/guided-project/{slug} (steps as JSON in the Accept-Language asked, if framework.enabled_locales declares it, the default locale otherwise: label, description, narration, url, highlight). It writes one starts entry per step, or the steps go untimed.

Content zones

A screen the app's own controller renders (a form, a preview, a list) takes its prose from a Page: <twig:c975LSite:Page:Blocks slug="shortcut-preview"/> renders that Page's blocks through render_owned_blocks() - same cache, locale and editor overlay as a page display - published or not. The Page is a content holder, left unpublished so /pages/{slug} stays 404 and the sitemap leaves it out. No page yet: nothing for a visitor, a link to PageCrudController's new action with ?slug= prefilled (createEntity()) for site-role-editor. A trashed page renders nothing for anyone. Convention, no flag: slug named after the screen, title starting with Écran - , Published filter on the index.

Commands

php bin/console c975l:site:pages:import-defaults        # home + the legal pages if absent, legal descriptions realigned
php bin/console c975l:site:collection-item:import --group=<group> --json-file=<path>
php bin/console c975l:site:create                       # one-shot wizard bootstrapping a new site
php bin/console c975l:translate:content --locale=en --dry-run  # UiBundle's; SiteTextProvider hands it the pages, menus and collection items

Twig functions

site_page(id), site_content_page(slug), site_page_new_url(slug), site_legal_pages(models), site_page_for_form_block(formName), page_health_check(page), page_title(page), page_summary(page), page_alternates(page). All declared with #[AsTwigFunction] on the method backing them — a site overriding one decorates the service and carries the attribute over.

Read a page's title through page_title(page), not page.title, wherever a visitor sees it: the raw column is the writing language's, whatever language the page is being read in.

Demo dataset

SiteDemoFixtureProvider (UiBundle's DemoFixtureProviderInterface) hands a demo site three published pages with their blocks and a collection of three items, read by a collection block on nos-services - loading it is that site's own business, this bundle shipping no command that writes to a database. The content is 975L's own, each item leading to one of its real sites (an outside URL); a site wanting another demo decorates the provider. The second pass lays a navbar only where none exists. The pages carry written-down creation dates and are left out of the index; their blocks ride the cascade, while each CollectionItem is handed over on its own, nothing cascading off a CollectionGroup. home holds hero, feature_bar, section_features, process_steps, faq, cta_band; nos-services holds text_section, section_features, process_steps, collection, cta_band; notre-histoire holds text_section, feature_bar, process_steps, text_section, cta_band. The hero's buttons and the three cta_band are linked as page:ID in the second pass (getLinkedDemoFixtures()), never as a raw path. The collection block uses the portfolio variant; an item's picture is the site's keyed_images entry site/<slug> when declared, a picture of the images pool otherwise.

Social publication

With SocialBundle installed, PageSocialContentSource (UiBundle's SocialContentSourceInterface, source type page) hands its automatic publication the oldest page not posted yet, once, with the page's own sharing image or as text without one. Unreferenced pages (isReferenced()) and pages holding a legal_model block are never offered, and nothing goes out while site-url is empty. What was posted where is SocialBundle's to record. The candidates come from PageRepository::findSocialCandidates(), which applies that rule in SQL (oldest first, then by id) without loading blocks; getContent() re-checks a single page in PHP (isPostable()). The source is also browsable (BrowsableSocialContentSourceInterface): findContents() lists the free pages latest first, a page without a public url never taking a place. With SocialBundle, the pages' index shows a "Réseaux sociaux" badge (UiBundle's SocialStatusCrudTrait) read from SocialContentStatusProviderInterface for the rows of the page shown, never on the trash; the badge's date format is the translation label.page_social_date_format.

Do not

  • Do not change which pages may be posted in one place only — findSocialCandidates() (SQL) and PageSocialContentSource::isPostable() (PHP) hold the same rule.
  • Do not add a menu to the demo dataset — a site's navigation is its own content, and a reload would put back what it thinks belongs there.
  • Do not create a file-based page for content that must be crawled — it is absent from the sitemap by construction.
  • Do not write a redirect mechanism. ConfigBundle's Redirect rows answer before the router, for every bundle's urls.
  • Do not add a column to Page for a display option. It goes in Page::$options, behind a named accessor.
  • Do not re-publish a page expecting it to be indexed again — isIndexable has to be checked back deliberately.
  • Do not build a page-template feature. Duplicate is the answer.
  • Do not set isIndexable to false to hide a members' page. Read isReferenced(); the flag is kept for the day the page is opened again.
  • Do not publish a content holder, nor hardcode a screen's prose in the app's template. A <twig:c975LSite:Page:Blocks/> zone reads the unpublished page; publishing it opens a second url.
  • Do not add a free-text collection field on an item. The group is picked by the screen it is opened from.
  • Do not create a Page per collection item. One detail page serves the whole collection.
  • Do not add a block kind without drawing its silhouette — the picker then shows it as a bare frame, which is what the picker exists to avoid.
  • Do not cache a preview render.
  • Do not let a media purge walk public/medias/films — no row points at the tutorial films, so a purge of unreferenced files would delete them all.
  • Do not add a locale column to Page, nor duplicate a page per language. The translations live beside the row; the structure is edited once.
  • Do not render a collection item detail before the gate — a deleted or unpublished page, or a language the gate redirects from, would be rendered for nothing.
  • Do not empty the block cache when a page enters or leaves a language — no cached html holds a localised link, BlockExtension::localizeLinks() running outside the cache on every request.
  • Do not drop OPENED_VERSION_FIELD from an overridden edit form — without it the lock never fires and the save made last silently overwrites the other tab.
  • Do not replace an entity's title in memory to translate it — Doctrine writes it back on the next flush. Read it through page_title() / PageTranslator.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource