Symfony bundle that provides a complete foundation for building websites — layout, pages, SEO, admin, sitemap, legal templates, and more.
- Base layout with SEO-optimized meta tags (OpenGraph, robots, canonical, favicon, Apple touch icon)
- Page display from Twig templates (file-based) or from the database via the
Pageentity - Page redirects and 410 Gone handling
- Admin CRUD for database pages via EasyAdmin
- Admin CRUD for users via EasyAdmin, with role management
- Admin CRUD for the site's navbar/footer menus via EasyAdmin
- Admin CRUD for the site's graphics (favicon, Apple touch icon, logo, default Open Graph image) via EasyAdmin
- Sitemap generation from both filesystem templates and database pages, with a "Regenerate sitemap" dashboard shortcut
- Error page templates for 401, 403, 404, 410, and 500
- Legal model templates for France (French): cookies, copyright, legal notice, privacy policy, terms of sales, terms of use
- Matomo analytics integration
- CookieConsent integration
- Alternate language hreflang meta tags
- Open Graph image support
- Email templates with CSS inlining
- Asset serving controller (inline display, access-protected)
- File download controller (forced download, access-protected)
- Twig extensions:
route_exists,template_exists,asset_exists,nl2br - File lists:
extensions.txtandbots.txt
- PHP >= 8.1
- c975L/ConfigBundle
- c975L/UiBundle
- Doctrine ORM
- EasyAdmin
- symfony/ux-twig-component
- twig/cssinliner-extra
composer require c975l/site-bundleThis bundle uses c975L/ConfigBundle to manage its settings. Load the default configuration keys into the database:
php bin/console c975l:config:load-allThen open the ConfigBundle dashboard to set values for the keys
Add the bundle routes to config/routes.yaml:
c975_l_site:
resource: "@c975LSiteBundle/Controller/"
type: attribute
prefix: /
# For multilingual websites:
# prefix: /{_locale}
# defaults:
# _locale: '%locale%'
# requirements:
# _locale: en|fr|esphp bin/console assets:install --symlinkThis bundle ships Stimulus controllers (basic, matomo, cookieConsent). They are exposed via AssetMapper under the @c975l/site-bundle namespace.
Add one entry to importmap.php (one-time, at installation):
'@c975l/site-bundle/controllers.js' => [
'path' => './vendor/c975l/site-bundle/assets/controllers.js',
],Add two lines to assets/bootstrap.js (or assets/stimulus_bootstrap.js):
import { startStimulusApp } from '@symfony/stimulus-bundle';
import { register as registerc975lSite } from '@c975l/site-bundle/controllers.js';
const app = startStimulusApp();
registerc975lSite(app);After that, all controllers are loaded with hashed filenames (cache busting). Adding or removing controllers in a future bundle update requires no change in your app.
Create templates/layout.html.twig in your project and extend the bundle's layout:
{% extends '@c975LSite/layout.html.twig' %}Declare these variables in each page template to populate meta tags and the page title:
{% set title = 'My Page Title' %}
{% set description = 'A short description of this page.' %}The layout exposes the following Twig blocks for you to override or extend:
| Block | Description |
|---|---|
head |
Entire <head> element |
meta |
Meta tags (charset, viewport, robots, og:*, etc.) |
stylesheets |
CSS links |
preconnect |
<link rel="preconnect"> hints |
body |
Entire <body> element |
header |
Site header |
navigation |
Main navigation |
main |
Main content wrapper |
title |
Page <h1> title |
flashes |
Flash messages |
container |
Container div wrapping content |
content |
Page-specific content |
share |
Sharing widgets |
navigationBottom |
Bottom navigation |
footer |
Site footer |
javascripts |
JavaScript includes |
Override a block:
{% block share %}
{{ parent() }}
{# your additional content #}
{% endblock %}Disable a block:
{% block share %}{% endblock %}Use the display variable to conditionally include templates (defaults to html):
{% if display == 'pdf' %}
{% include 'header-pdf.html.twig' %}
{% else %}
{% include 'header.html.twig' %}
{% endif %}Place Twig templates in templates/pages/. They are served at /pages/{slug} via the page_display route.
To hint the sitemap generator, add metadata in a Twig comment at the top of the file:
{# changeFrequency="monthly" priority="8" #}| Location | Effect |
|---|---|
templates/pages/redirected/{slug}.html.twig |
Redirects to the slug written inside the file |
templates/pages/deleted/{slug}.html.twig |
Throws a 410 Gone exception |
Use the Page entity to manage pages through the database. Each page supports:
- Title, slug (unique), description
- Published status and display position
- Sitemap fields: change frequency and priority (0–10)
- Blocks (content blocks from c975L/UiBundle)
- Creation / modification timestamps and author reference
Database pages are rendered with the bundle's @c975LSite/pages/page.html.twig template, which displays the page title, description, and its associated blocks.
On top of the generic block system provided by c975L/UiBundle, SiteBundle registers the following blocks (see config/services.yaml):
| Kind | Category | Description |
|---|---|---|
legal_model |
label.category_legal |
Renders one of the built-in legal page models (cookies policy, copyright, legal notice, privacy policy, terms of sales, terms of use), localized under templates/models/{country}/{model}.html.twig. Optionally displays a "latest update" date. |
twig_content |
label.category_migration |
Renders raw Twig content stored in the block's data via template_from_string(). Intended as a migration/escape hatch for content that doesn't fit another block type. |
articles_slider |
label.category_navigation |
Picks another database page and renders its article blocks (that have at least one media) as a clickable slider, using the <twig:c975LUi:Slider:Slider> component from UiBundle. |
Each block is registered as a ui.block-tagged service, with a dedicated form (c975L\SiteBundle\Form\Block\*Type) and template (templates/blocks/*.html.twig). The articles_slider block relies on the site_page(id) Twig function (PageExtension) to eager-load the target page along with its blocks and medias.
Pages are managed in the EasyAdmin dashboard via PageCrudController. The menu entry is registered automatically through MenuProvider. Access is controlled by the site-role-needed key in ConfigBundle.
The site-wide navbar and footer are managed entirely from the database — no app-side template override needed. Each is a Menu (location: navbar or footer, one row per location, same singleton pattern as the site-wide graphics managed via SiteGraphicCrudController) owning an ordered collection of MenuItem rows, each targeting either:
- an existing published
Page(linked by its id, so renaming the page's slug never breaks the link) or - a route contributed by another bundle (see Linking to a bundle's own route below)
Managed via MenuCrudController (drag-and-drop reordering, same mechanism as Blocks). Access is controlled by the site-role-needed key in ConfigBundle.
Both are rendered by built-in components already wired into the bundle's layout (navigation/footer blocks) — nothing to add in your app:
<twig:c975LSite:General:Navbar/>
<twig:c975LSite:General:Footer copyright="{{ copyright }}"/>An item disappears from the rendered menu automatically (no dangling link) if its page is later unpublished/deleted, or if its route's contributing bundle is removed.
Navbar reads site_media('logo'), config('site-name') and config('site-tagline') — nothing to pass in. site-name stays mandatory (used across meta tags, page titles, etc.), but showing it in the navbar specifically is optional via the site-navbar-show-name ConfigBundle key (bool, default true).
site-tagline is authored as rich text in the backoffice (Trix wraps the value in its own <div>), so it's rendered with |raw — style .menu-site-tagline in your own SCSS if you need to adjust it.
A MenuItem isn't limited to database pages. Any bundle can expose one of its own front-end routes (e.g. ContactFormBundle's /contact) as a selectable target by implementing ConfigBundle's LinkableRouteProviderInterface — see ConfigBundle's README for how to write the provider. This is how ContactFormBundle exposes its contact page; the same approach will apply to ShopBundle and BookBundle.
Deliberately not a ui.block — a Menu item is site-wide chrome (like the navbar/footer themselves), not page content, so it isn't selectable from the page content-block picker.
App\Entity\User is managed in the EasyAdmin dashboard via UserCrudController. The menu entry is registered automatically through MenuProvider. Access is controlled by the site-role-needed key in ConfigBundle, same as pages.
The controller relies on EasyAdmin's auto-discovery of the app's own User fields (which vary per app), except for:
- The hashed password field, excluded so it's never displayed or overwritten from the backoffice
- The
rolesfield, added explicitly as a multiple-choice field, since JSON columns are never auto-discovered by EasyAdmin - The
creation/modificationfields, made readonly since they're set automatically
ROLE_USER is always excluded from the choices (every user already has it by default, see User::getRoles()). The other selectable roles come from the user-roles-available ConfigBundle key (json kind, e.g. ["ROLE_ADMIN","ROLE_EDITOR"]) — add roles for your app there, no code change needed.
The detail page is disabled (not useful on top of the index and edit pages).
The user-registration-enabled ConfigBundle key (bool, default false) lets you turn the app's registration route on or off without a deployment. This bundle doesn't provide a RegistrationController (it's generated by symfony/make in your app), so wire the check yourself at the top of its register action:
// Access denied if registration is disabled in the configuration
if (false === $this->configService->get('user-registration-enabled')) {
throw $this->createAccessDeniedException();
}Run the following command to generate public/sitemap-pages.xml:
php bin/console c975l:site:sitemaps:createThe command aggregates URLs from:
- Twig files in
templates/pages/(readschangeFrequencyandpriorityfrom comments) - Published database pages (uses their
changeFrequencyandpriorityfields)
A sitemap index template is also available at @c975LSite/sitemap-index.xml.twig.
The same command can also be triggered from the dashboard, via a "Regenerate sitemap" shortcut contributed through ConfigBundle's ShortcutProviderInterface.
Define languagesAlt to add <link rel="alternate" hreflang="..."> tags and enable a language switcher navbar component:
{% set languagesAlt = {
en: { title: 'English' },
fr: { title: 'Français' },
es: { title: 'Español' }
} %}URLs are built as https://example.com/{locale}/pages/{slug}.
Resolved in this order: an ogImage variable set by the template/page takes priority, then a database Page's own ogImage (settable from PageCrudController), then the site-wide default og-image managed via Site graphics, then the site's logo.
To override it manually for a file-based page:
{% set ogImage = absolute_url(asset('images/my-og-image.jpg')) %}The site's favicon, Apple touch icon, logo and default Open Graph image are each a c975L\UiBundle\Entity\Media row carrying a role (Media::ROLE_FAVICON, ROLE_APPLE_TOUCH_ICON, ROLE_LOGO, ROLE_OG_IMAGE) — not plain ConfigBundle text paths. Managed via SiteGraphicCrudController (one row per role, uploaded file always saved at a fixed well-known path, e.g. /favicon.ico, whatever gets re-uploaded). Dashboard alerts (via ConfigBundle's AlertProviderInterface) flag any role not yet uploaded, and UiBundle's Media library shows where each one is used (via SiteMediaUsageProvider) — as a site graphic, a page's og-image, or a media attached to a page's block.
Access is controlled by the site-role-needed key in ConfigBundle, same as pages.
All components below read their data from ConfigBundle. No props are needed — just include the tag and set the corresponding keys via the ConfigBundle dashboard.
Set site-matomo-url and site-matomo-id in ConfigBundle, then place the component wherever you want the tracking snippet (typically just before </body>):
<twig:c975LSite:General:Matomo/>The component renders nothing if either config value is missing.
Set url-cookies-policy in ConfigBundle (optional — links the banner to your cookies page), then place the component in your layout:
<twig:c975LSite:General:CookieConsent/>The message, dismiss, and link texts are loaded from the site translation domain.
Set site-hosted-by-url + site-hosted-by-logo and/or site-made-by-url + site-made-by-logo in ConfigBundle, then include the components (typically in the footer):
<twig:c975LSite:General:HostedBy/>
<twig:c975LSite:General:MadeBy/>Each component renders nothing if either its URL or logo config value is missing.
Set site-preconnect in ConfigBundle to a JSON array of external origins to preconnect to, i.e. ["https://975l.com"]. Useful when HostedBy/MadeBy logos or Matomo are served from a third-party domain. Empty by default, so it has no effect unless configured.
Pre-built error templates are available for: error, error401, error403, error404, error410, and error500.
Follow the Symfony guide on customizing error pages, then include the bundle templates in your own error files:
{% extends 'layout.html.twig' %}
{% block content %}
{% include '@c975LSite/Exception/error404.html.twig' %}
{% endblock %}
{% block share %}{% endblock %}Pre-built legal templates are available for France in French (fr). Available models:
| Model | Path |
|---|---|
| Cookies policy | @c975LSite/models/france/fr/cookies.html.twig |
| Copyright | @c975LSite/models/france/fr/copyright.html.twig |
| Legal notice | @c975LSite/models/france/fr/legal-notice.html.twig |
| Privacy policy | @c975LSite/models/france/fr/privacy-policy.html.twig |
| Terms of sales | @c975LSite/models/france/fr/terms-of-sales.html.twig |
| Terms of use | @c975LSite/models/france/fr/terms-of-use.html.twig |
Each model is also available in Markdown format (.md).
Feel free to contribute translations or add templates for other countries.
{% extends 'layout.html.twig' %}
{% trans_default_domain 'site' %}
{% set title = 'label.terms_of_sales'|trans %}
{% block content %}
{% set latestUpdate = '2024-01-01' %}
{% include '@c975LSite/models/france/fr/terms-of-sales.html.twig' %}
{% endblock %}{% extends 'layout.html.twig' %}
{% trans_default_domain 'site' %}
{% set title = 'label.terms_of_sales'|trans %}
{% block content %}
{% set latestUpdate = '2024-01-01' %}
{% embed '@c975LSite/models/france/fr/terms-of-sales.html.twig' %}
{# Disable a block #}
{% block acceptation %}{% endblock %}
{# Or extend a block #}
{% block acceptation %}
{{ parent() }}
Additional content here.
{% endblock %}
{% endembed %}
{% endblock %}Serves a file inline (e.g., images, PDFs). Useful for serving files only to authenticated users.
{{ path('asset_file', { file: 'path/to/your_file.pdf' }) }}To restrict access, add an entry to config/packages/security.yaml:
access_control:
- { path: ^/asset/protected/, roles: ROLE_USER }Forces a file download.
{{ path('download_file', { file: 'path/to/your_file.csv' }) }}File names may contain letters (including accented), digits, -, _, /, and up to two extensions. Spaces are not allowed.
| Function / Filter | Description |
|---|---|
route_exists('route_name') |
Returns true if the named route exists |
template_exists('template.html.twig') |
Returns true if the template file exists |
asset_exists('path/to/file') |
Returns true if the asset exists in public/ or assets/ |
|nl2br |
Applies PHP's nl2br() with HTML output safe |
Pre-built email templates are available at @c975LSite/emails/:
| Template | Description |
|---|---|
layout.html.twig |
Base email layout |
fullLayout.html.twig |
Full email layout |
footer.html.twig |
Email footer |
CSS is inlined automatically via twig/cssinliner-extra. Minified stylesheets (emails.min.css, styles.min.css) are embedded.
Link the animations stylesheet to use scroll-triggered CSS animations:
<link rel="stylesheet" href="{{ asset('bundles/c975lsite/css/animations.min.css') }}">| Command | Description |
|---|---|
php bin/console c975l:site:sitemaps:create |
Generates public/sitemap-pages.xml from filesystem and database pages |
php bin/console c975l:site:backup |
Backs up the database and public/ files |
php bin/console c975l:site:pages:import-defaults |
Creates default pages (home, legal notice, privacy policy, CGU, CGV, cookies) if they do not already exist |
Run once after setting up a new site to pre-populate the database with common pages:
php bin/console c975l:site:pages:import-defaultsPages created:
| Slug | Title | Block |
|---|---|---|
home |
Home | — |
legal-notice |
Mentions légales | legal_model → france/legal-notice |
privacy-policy |
Politique de confidentialité | legal_model → france/privacy-policy |
terms-of-use |
Conditions générales d'utilisation | legal_model → france/terms-of-use |
terms-of-sales |
Conditions générales de vente | legal_model → france/terms-of-sales |
cookies |
Politique de cookies | legal_model → france/cookies |
All pages are created as unpublished — review and publish them individually from the admin. Pages whose slug already exists are silently skipped.
The bundle provides site:sitemaps:create and site:backup as schedulable commands. The schedule itself is defined in your app so each project controls its own timing.
// src/Scheduler/SiteSchedule.php
namespace App\Scheduler;
use Symfony\Component\Console\Messenger\RunCommandMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule('site')]
class MaintenanceSchedule implements ScheduleProviderInterface
{
public function __construct(
private readonly CacheInterface $cache,
) {}
public function getSchedule(): Schedule
{
return (new Schedule())
->stateful($this->cache)
// Sitemap: daily at 00:05
->add(RecurringMessage::cron('5 0 * * *', new RunCommandMessage('site:sitemaps:create')))
// Partial backup: every 6 hours (DB regular tables + modified files only)
->add(RecurringMessage::cron('7 */6 * * *', new RunCommandMessage('site:backup')))
// Full backup + report: every Monday at 03:07 (archive tables + whole DB + all user files)
->add(RecurringMessage::cron('7 3 * * 1', new RunCommandMessage('site:backup --full --report')));
}
}The stateful() call persists the last-run time via Symfony Cache so tasks are not re-run if the worker restarts.
Run the consumer as a long-lived process (supervised by Supervisor or systemd):
php bin/console messenger:consume scheduler_siteYou may keep a cron entry that restarts the worker daily (e.g., at 00:25) to recover from crashes without monitoring the process continuously:
25 0 * * * systemctl --user start messenger-worker@your-site.serviceTwo plain-text lists are available for validation purposes:
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
$extensions = file(
$this->parameterBag->get('kernel.project_dir') . '/../vendor/c975l/site-bundle/Lists/extensions.txt',
FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES
);
$bots = file(
$this->parameterBag->get('kernel.project_dir') . '/../vendor/c975l/site-bundle/Lists/bots.txt',
FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES
);{% extends '@c975LSite/layout.html.twig' %}
{% set languagesAlt = {
en: { title: 'English' },
fr: { title: 'Français' },
es: { title: 'Español' }
} %}
{% block meta %}
{{ parent() }}
<meta property="fb:app_id" content="YOUR_FACEBOOK_APP_ID">
{% endblock %}
{% block stylesheets %}
{{ parent() }}
{% endblock %}
{% block navigation %}
{{ include('navbar.html.twig') }}
{% endblock %}
{% block title %}
{% if app.request.get('_route') is not null %}
<h1>{{ title }}</h1>
{% endif %}
{% endblock %}
{% block container %}
<div class="container">
{% block content %}{% endblock %}
</div>
{% endblock %}
{% block share %}
{# your sharing widget #}
{% endblock %}
{% block footer %}
{{ include('footer.html.twig') }}
<twig:c975LSite:General:HostedBy/>
<twig:c975LSite:General:MadeBy/>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<twig:c975LSite:General:CookieConsent/>
<twig:c975LSite:General:Matomo/>
{% endblock %}If this project helps you save development time, consider sponsoring via the Sponsor button at the top of the GitHub page. Thank you!