florentingarnier / cookie-consent-bundle
Symfony integration of florentingarnier/cookie-consent: a consent banner, a browser script blocking the scripts and embedded contents until the visitor consents, and the proof of each choice.
Package info
github.com/FlorentinGarnier/cookie-consent-bundle
Type:symfony-bundle
pkg:composer/florentingarnier/cookie-consent-bundle
Requires
- php: ^8.2
- doctrine/doctrine-bundle: ^2.10 || ^3.0
- doctrine/orm: ^2.14 || ^3.0
- doctrine/persistence: ^3.1 || ^4.0
- florentingarnier/cookie-consent: ^0.1
- symfony/asset: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/config: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/console: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/dependency-injection: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/http-foundation: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/http-kernel: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/routing: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/twig-bundle: ^5.4 || ^6.4 || ^7.4 || ^8.0
- twig/twig: ^3.3
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.0
- symfony/browser-kit: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/framework-bundle: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/translation: ^5.4 || ^6.4 || ^7.4 || ^8.0
- symfony/yaml: ^5.4 || ^6.4 || ^7.4 || ^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Symfony integration of florentingarnier/cookie-consent. It asks the visitors for their consent to cookies, purpose by purpose, blocks the scripts and the embedded contents until they consent, and keeps a proof of each choice. It follows the recommendations of the French data protection authority (CNIL):
- nothing optional is loaded before the visitor chooses;
- rejecting is as easy as accepting: both buttons have the same style, on the banner and in the preferences;
- the visitor can accept or reject each purpose, and sees the third parties behind it;
- the visitor can change or withdraw the consent at any time, from a link in the footer; the cookies of a withdrawn purpose are deleted;
- the choice is kept for 6 months by default (13 months at most), then the visitor is asked again;
- each choice is recorded, without any personal data, so that it can be proven.
No external service, no build step: one module script and one style sheet, themable with CSS custom properties.
Using Sylius? See atriametal/sylius-cookie-consent-plugin, which renders the banner in the shop and lists the records in the admin.
Requirements
- PHP 8.2 or later
- Symfony 5.4, 6.4, 7.4 or 8.x, with Twig, the assets and the translator enabled. Symfony 8 requires PHP 8.4.
- Doctrine ORM, to store the records
- JavaScript in the visitor's browser: without it, nothing optional is loaded
Installation
-
Require the bundle:
composer require florentingarnier/cookie-consent-bundle
-
If you do not use Symfony Flex, enable the bundle:
// config/bundles.php return [ // ... FlorentinGarnier\CookieConsentBundle\FlorentinGarnierCookieConsentBundle::class => ['all' => true], ];
-
Import the route receiving the choices. Keep it outside any locale prefix:
# config/routes/florentin_garnier_cookie_consent.yaml florentin_garnier_cookie_consent: resource: '@FlorentinGarnierCookieConsentBundle/config/routes.php'
-
Install the assets and create the
cookie_consent_recordtable:bin/console assets:install bin/console doctrine:migrations:diff bin/console doctrine:migrations:migrate
-
Render the templates in your layout: the style sheet in the
<head>, before your Google tags, and the banner at the top of the<body>, so that keyboard and screen reader users reach it first:<head> {{ include('@FlorentinGarnierCookieConsent/stylesheets.html.twig') }} {# your Google tags #} </head> <body> {{ include('@FlorentinGarnierCookieConsent/banner.html.twig') }} {# … #} <footer>{{ include('@FlorentinGarnierCookieConsent/preferences_link.html.twig') }}</footer> {{ include('@FlorentinGarnierCookieConsent/javascripts.html.twig') }} </body>
-
Describe your cookies (see Configuration) and block your scripts and embedded contents (see Blocking).
-
Remove the consent records older than the retention period every day, for instance with cron:
bin/console cookie-consent:purge-records
Configuration
# config/packages/florentin_garnier_cookie_consent.yaml florentin_garnier_cookie_consent: # Change it to ask every visitor again, for instance after adding a category or a service policy_version: '1' cookie: name: cookie_consent lifetime: 182 # days, 395 at most domain: ~ # ".example.com" shares the consent between subdomains privacy_policy: route: ~ # the page describing your cookies, linked from the banner parameters: {} # Replace the default categories: necessary (required), analytics, marketing and media categories: necessary: required: true # always granted: only for the cookies strictly necessary to the site analytics: services: ['Google Analytics'] # shown to the visitor cookies: ['_ga', '_ga_*'] # deleted when the visitor withdraws the consent media: services: ['YouTube'] google_consent_mode: enabled: false signals: # the category granting each signal analytics_storage: analytics ad_storage: marketing ad_user_data: marketing ad_personalization: marketing records: retention: 395 # days # The Doctrine entity storing the records; it must extend FlorentinGarnier\CookieConsent\ConsentRecord class: FlorentinGarnier\CookieConsentBundle\Entity\ConsentRecord # A service implementing FlorentinGarnier\CookieConsentBundle\Channel\ChannelCodeProviderInterface, recorded with # each choice, for an application with several sites or channels channel_code_provider: ~
The label and the description of each category are translated from
florentin_garnier_cookie_consent.categories.<name>.label and .description, in the messages domain. The bundle
translates the default categories in English, French and German. Override any text in your
translations/messages.<locale>.yaml: the texts of the banner must describe the purposes you actually use.
Storing the records in your own entity
The bundle maps the fields of FlorentinGarnier\CookieConsent\ConsentRecord as a mapped superclass. To store the
records in another table, or to add to them, extend it with an entity of your own and set records.class: the entity
of the bundle is then not mapped.
#[ORM\Entity] #[ORM\Table(name: 'app_cookie_consent_record')] class ConsentRecord extends \FlorentinGarnier\CookieConsent\ConsentRecord { }
Blocking scripts and embedded contents
A script runs once a visitor consents to any of the categories listed in its data-cookie-consent attribute.
Give it the text/plain type, so that the browser does not run it before:
<script type="text/plain" data-cookie-consent="analytics marketing"> (function(w,d,s,l,i){ /* Google Tag Manager */ })(window,document,'script','dataLayer','GTM-XXXX'); </script> <script type="text/plain" data-cookie-consent="analytics" src="https://example.com/tracker.js" async></script>
A module script keeps its type in data-type="module".
An embedded content gets its address from data-src. Until the visitor consents, a placeholder explains why it is
hidden and lets the visitor allow its category; data-cookie-consent-service names the third party in it:
<iframe data-cookie-consent="media" data-cookie-consent-service="YouTube" data-src="https://www.youtube-nocookie.com/embed/VIDEO_ID" title="…"></iframe>
On the server side, cookie_consent_granted('media') tells whether the visitor consented. Do not use it in pages
cached for every visitor.
Google consent mode
With google_consent_mode.enabled, the style sheet template sets the default state of the configured signals before
any Google tag loads: granted for what the visitor already consented to, denied otherwise. The script then sends each
new choice with gtag('consent', 'update', …). Render the style sheet template before your Google tags and before any
event you push to the data layer: Google tags evaluate each event with the consent state of that moment. As it reads
the consent cookie, do not render it in pages cached for every visitor. The consent mode does not replace the
blocking: without consent, Google tags still send cookieless pings.
JavaScript
window.cookieConsent.isGranted('analytics'); // once the script has run window.cookieConsent.open(); // opens the preferences // Dispatched when the script starts, then after each choice document.addEventListener('cookie-consent:update', (event) => { if (event.detail.categories.includes('analytics')) { // start what needs this consent } });
Any element with data-cookie-consent-action="open" opens the preferences. A script cannot be unloaded: when the
visitor withdraws a consent, the page is loaded again, after the cookies of the category are deleted.
Theming
Override the custom properties of .cookie-consent and .cookie-consent-placeholder:
.cookie-consent, .cookie-consent-placeholder { --cookie-consent-font-family: 'Inter', sans-serif; --cookie-consent-text: #121212; --cookie-consent-button-background: #121212; --cookie-consent-radius: 0; }
See public/cookie-consent.css for the full list. Keep accepting and rejecting with the same style.
Proof of consent
Each choice is recorded with a random identifier, the version of the policy, the granted categories, the date and,
if you configured a channel_code_provider, the channel: no IP address, no user. The identifier stays the same when
the visitor changes the choice, and is shown in the preferences, so that the visitor can refer to it.
Limitations
- Without JavaScript, nothing is unblocked and the banner is not shown.
- The texts of the banner are yours: they must match the categories and services you configure.
- Another site cannot make a visitor consent: the choice is only accepted as JSON, which browsers cannot send from another site without a CORS preflight.
Development
composer install vendor/bin/phpunit # PHP 8.2 or later, with pdo_sqlite vendor/bin/phpstan analyse node --test tests/js/*.test.mjs # browser script, Node.js 22 or later