RGPD/GDPR-first cookie consent addon for Statamic 6.
It gives you a Shadow DOM cookie banner, per-site Control Panel settings, Google Consent Mode v2 defaults/updates, generic script and iframe gating for vendors like GTM, GA4, Meta Pixel, LinkedIn Insight Tag, TikTok Pixel, Hotjar, Clarity, and embeds, plus an append-only server-side consent log for proof of consent.
The public banner includes a small Powered by estou.ai attribution.
Features
- RGPD/GDPR-first default copy in Portuguese.
- Per-site Statamic Control Panel settings.
- Enable/disable toggle per site.
- Versioned consent: bump the version to force visitors to decide again.
- Google Consent Mode v2 support (
ad_storage,analytics_storage,ad_user_data,ad_personalization,security_storage). - Inline Consent Mode defaults for
<head>before GTM/gtag.js loads. - Generic
data-cookie-categoryscript and iframe gating for non-Google vendors. - Content gating tags for Antlers templates.
- Server-side JSONL consent log for RGPD Art. 7º(1) / LGPD Art. 8º proof, with automatic retention pruning and CSV export.
- Global Privacy Control auto opt-out.
- Equal prominence accept/reject buttons.
- Shadow DOM styling with
::part()hooks. Powered by estou.aibranding.
Installation
composer require estouai/cookie-consentphp artisan vendor:publish --tag=cookie-consent-configphp artisan vendor:publish --tag=cookie-consent --force
If you install from a local path repository during development:
{ "repositories": [ { "type": "path", "url": "addons/estouai/cookie-consent" } ], "require": { "estouai/cookie-consent": "*@dev" }}
Then run:
composer update estouai/cookie-consent
Recommended template setup
Put defaults before any GTM/gtag.js snippet:
<head> {{ cookie_consent:defaults }} <!-- GTM / gtag.js goes here, after defaults --></head>
Put banner/button/scripts near the end of the page. Keep scripts outside
{{ nocache }} so they work with full-measure static caching:
{{ cookie_consent }}{{ cookie_consent:button }}{{ cookie_consent:scripts }}
Tags
{{ cookie_consent }}
Renders the banner web component.
{{ cookie_consent }}
Suppress it manually:
{{ cookie_consent hidden="true" }}
{{ cookie_consent:defaults }}
Renders inline Consent Mode defaults. Use in <head> before GTM/gtag.js.
Defaults grant only required groups and deny non-essential groups:
gtag('consent', 'default', { security_storage: 'granted', analytics_storage: 'denied', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied'})
This is what GTM Consent Settings / built-in consent variables read before the first pageview.
{{ cookie_consent:scripts }}
Loads the compiled JavaScript bundle. It registers custom elements, applies stored consent, updates Consent Mode, activates gated scripts/iframes, resolves content gates, handles Global Privacy Control, and logs decisions server-side.
{{ cookie_consent:button }}
Renders a floating button that reopens preferences.
{{ cookie_consent:allowed }} / {{ cookie_consent:denied }}
Client-side content gating by group:
{{ cookie_consent:allowed group="marketing" }} <div>Marketing-only content</div>{{ /cookie_consent:allowed }} {{ cookie_consent:denied group="marketing" }} <p>Accept marketing cookies to see this embed.</p>{{ /cookie_consent:denied }}
When the addon is disabled for the current site, allowed renders directly and
denied renders nothing.
{{ cookie_consent:groups }}
Render a privacy-policy cookie table:
<table> {{ cookie_consent:groups }} <tr> <th>{{ name }}</th> <td>{{ description }}</td> <td>{{ legal_basis }}</td> </tr> {{ cookies }} <tr> <td>{{ name }}</td> <td>{{ purpose }}</td> <td>{{ retention }}</td> </tr> {{ /cookies }} {{ /cookie_consent:groups }}</table>
Script and iframe gating
Add data-cookie-category to any vendor script or iframe. Use
data-cookie-src instead of src for remote assets.
GA4 / gtag.js
<script data-cookie-category="analytics" data-cookie-src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script> <script data-cookie-category="analytics" type="text/plain">window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'G-XXXX');</script>
Meta Pixel
<script data-cookie-category="marketing" type="text/plain">!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window, document,'script','https://connect.facebook.net/en_US/fbevents.js');fbq('init', 'PIXEL_ID');fbq('track', 'PageView');</script>
LinkedIn Insight Tag
<script data-cookie-category="marketing" type="text/plain">_linkedin_partner_id = "PARTNER_ID";window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];window._linkedin_data_partner_ids.push(_linkedin_partner_id);(function(l) {if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};window.lintrk.q=[]}var s = document.getElementsByTagName("script")[0];var b = document.createElement("script");b.type = "text/javascript"; b.async = true;b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";s.parentNode.insertBefore(b, s);})(window.lintrk);</script>
Embeds
<iframe data-cookie-category="marketing" data-cookie-src="https://www.youtube.com/embed/VIDEO_ID" title="Video"></iframe>
Control Panel
Go to Tools → Cookie Consent.
Permission: manage cookie consent settings.
The screen edits:
- enabled toggle
- consent version
- banner position
- theme mode
- copy
- cookie groups JSON
- Consent Mode mapping JSON
Multi-site installs get a site switcher. Each site stores independent settings under:
content/cookie-consent/{site}/settings.yaml
If no CP settings exist, the addon uses config/cookie-consent.php defaults.
Consent Mode v2 and GTM
{{ cookie_consent:defaults }} writes default denied/granted values before GTM
loads. {{ cookie_consent:scripts }} later sends gtag('consent', 'update', ...) after a visitor chooses.
Default mapping:
'consent_mode' => [ 'necessary' => ['security_storage'], 'analytics' => ['analytics_storage'], 'marketing' => ['ad_storage', 'ad_user_data', 'ad_personalization'],]
GTM tags should still use Consent Settings / Additional Consent Checks for the signals they require. The addon provides the signal state; GTM enforces it per tag.
2026 consent rules covered
- Global Privacy Control. If
navigator.globalPrivacyControl === trueand no stored decision exists yet, non-essential groups are auto-rejected without flashing the banner. The decision is logged withsource: 'gpc'. - Equal prominence. Accept and reject buttons on the first layer use equal visual weight. Customize stays secondary.
- Google June 2026 Consent Mode change.
ad_storageis denied by default until the marketing group is granted, so Google Ads data stays gated by the consent signal that now matters.
Not implemented: IAB TCF v2.2+. Add a dedicated TCF CMP if you sell ad inventory programmatically through real-time bidding.
Consent log
Every accept/reject/save decision is sent to:
POST /!/cookie-consent/log
It appends one JSON line per decision:
storage/app/cookie-consent/{site}/{YYYY-MM-DD}.jsonl
Fields:
versiongroupspagesitetimestampsource(explicitorgpc)ip_hash(SHA-256 of IP + app key, not raw IP)user_agent
Query examples:
grep '"ip_hash":"<hash>"' storage/app/cookie-consent/*/*.jsonljq -c 'select(.groups | index("marketing"))' storage/app/cookie-consent/*/*.jsonl
The endpoint is CSRF-exempt because static-cached pages may not have a CSRF cookie to send. It is throttled at 60 requests/minute per IP.
Retention
Keeping the log forever breaks storage limitation (RGPD Art. 5º(1)(e)), so the addon prunes it for you. Nothing to wire up — it registers its own daily task:
// config/cookie-consent.php'log' => [ 'retention_days' => 395, // 13 months],
The day is the file name, so pruning is a file delete — nothing is rewritten.
Set retention_days to null to keep everything and turn the task off.
This relies on Laravel's scheduler running at all, which needs the usual cron entry on the server:
* * * * * cd /path/to/site && php artisan schedule:run >> /dev/null 2>&1
To prune by hand, or to see what a different window would remove:
php please cookie-consent:prunephp please cookie-consent:prune --days=90 --dry-run
Export
For an authority or legal request, export the range as CSV:
php please cookie-consent:export storage/app/consent-2026.csv --from=2026-01-01 --to=2026-12-31php please cookie-consent:export /tmp/fr.csv --site=fr
Both commands stream line by line, so a year of logs never lands in memory.
What the log can and cannot prove
It demonstrates that consent was collected, with the timestamp, the banner
version and the groups accepted. It is not searchable by data subject: ip_hash
is a one-way hash of IP + APP_KEY, and a visitor does not know the IP they
browsed from months ago. Rotating APP_KEY also breaks correlation between old
and new hashes. Treat the log as aggregate accountability evidence, not as a
register for Art. 15/17 access and erasure requests.
The beacon is best-effort by design — a failed request never blocks or reverses the visitor's decision — so if the endpoint breaks, decisions go unlogged silently. Monitor it if the log matters to you.
JavaScript API
window.CookieConsent.showDialog()window.CookieConsent.showDialog(true) // reload after preferences changewindow.CookieConsent.preferences // { version, timestamp, groups, source }window.CookieConsent.allowedGroups // ['necessary', 'analytics']window.CookieConsent.allowedCookies // ['cookie_consent', '_ga', ...]window.CookieConsent.on('change', (event) => { console.log(event.detail)})
Styling
The banner and button use Shadow DOM. Style via parts:
cookie-consent-banner::part(dialog) {}cookie-consent-banner::part(text) {}cookie-consent-banner::part(link) {}cookie-consent-banner::part(button) {}cookie-consent-banner::part(groups) {}cookie-consent-banner::part(branding) {}cookie-consent-button::part(button) {}
Configuration
Published config lives at:
config/cookie-consent.php
Important keys:
enabledversiontextpositionthemebuttongroupsconsent_mode
Floating preferences button
button controls {{ cookie_consent:button }}:
'button' => [ 'enabled' => true, 'label' => 'Cookies', 'aria_label' => 'Cookie preferences', 'position' => 'bottom-right', // bottom-left | bottom-right | top-left | top-right 'background' => '#ffffff', 'foreground' => '#6b7280', 'border' => 'rgba(1, 57, 131, 0.12)', 'shadow' => '0 8px 24px rgba(1, 57, 131, 0.16)', 'icon' => 'cookie', // cookie | none | custom HTML/SVG string 'icon_background' => '#013983', 'icon_foreground' => '#ffffff',],
When the banner is open, the button hides automatically so it cannot overlap the dialog. Before first consent, it also stays hidden while the banner is visible.
License
Proprietary. See LICENSE.md.