Skip to content

975L/SiteBundle

Repository files navigation

SiteBundle

Symfony bundle that provides a complete foundation for building websites — layout, pages, SEO, admin, sitemap, legal templates, and more.

GitHub Packagist Version PHP Version


Features

  • 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 Page entity
  • 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.txt and bots.txt

Requirements


Installation

Download

composer require c975l/site-bundle

Load configuration values

This bundle uses c975L/ConfigBundle to manage its settings. Load the default configuration keys into the database:

php bin/console c975l:config:load-all

Then open the ConfigBundle dashboard to set values for the keys

Enable routes

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|es

Install assets

php bin/console assets:install --symlink

Register Stimulus controllers

This 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.


Usage

Creating your layout

Create templates/layout.html.twig in your project and extend the bundle's layout:

{% extends '@c975LSite/layout.html.twig' %}

Page-specific variables

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.' %}

Template blocks

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 %}

Display mode

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 %}

Pages

File-based pages

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" #}

Redirects and deleted pages

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

Database pages

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.

Blocks defined by this bundle

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.

Admin management

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.


Menus

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: logo, site name, tagline

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.

Linking to a bundle's own route

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.


Users

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 roles field, added explicitly as a multiple-choice field, since JSON columns are never auto-discovered by EasyAdmin
  • The creation / modification fields, 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).

Disabling registration

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();
}

SEO

Sitemap generation

Run the following command to generate public/sitemap-pages.xml:

php bin/console c975l:site:sitemaps:create

The command aggregates URLs from:

  1. Twig files in templates/pages/ (reads changeFrequency and priority from comments)
  2. Published database pages (uses their changeFrequency and priority fields)

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.

Alternate languages (hreflang)

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}.

Open Graph image

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')) %}

Site graphics

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.


General components

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.

Matomo

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.

CookieConsent

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.

HostedBy / MadeBy

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.

Preconnect

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.


Error templates

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 %}

Legal models

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.

Include the whole model

{% 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 %}

Select specific blocks (embed)

{% 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 %}

Asset and Download controllers

AssetController

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 }

DownloadController

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.


Twig extensions

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

Email templates

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.


CSS animations

Link the animations stylesheet to use scroll-triggered CSS animations:

<link rel="stylesheet" href="{{ asset('bundles/c975lsite/css/animations.min.css') }}">

Commands

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

Import default pages

Run once after setting up a new site to pre-populate the database with common pages:

php bin/console c975l:site:pages:import-defaults

Pages created:

Slug Title Block
home Home
legal-notice Mentions légales legal_modelfrance/legal-notice
privacy-policy Politique de confidentialité legal_modelfrance/privacy-policy
terms-of-use Conditions générales d'utilisation legal_modelfrance/terms-of-use
terms-of-sales Conditions générales de vente legal_modelfrance/terms-of-sales
cookies Politique de cookies legal_modelfrance/cookies

All pages are created as unpublished — review and publish them individually from the admin. Pages whose slug already exists are silently skipped.


Scheduler

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.

1. Create the schedule class

// 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.

2. Start the worker

Run the consumer as a long-lived process (supervised by Supervisor or systemd):

php bin/console messenger:consume scheduler_site

You 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.service

Lists

Two 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
);

Full layout example

{% 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!

About

Symfony bundle that provides a complete foundation for building websites — layout, pages, SEO, admin, sitemap, legal templates, and more.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors