Cookie Consent Guide¶
The framework ships an EU cookie banner that works the same way in every scaffold theme (Bootstrap, Tailwind, plain CSS) and every app style (MVC, SPA, hybrid). It covers:
- a banner on the first visit, where Accept all and Reject all are equally prominent, plus a Customise dialog with a switch per category;
- the choice, kept in the browser for 180 days and asked for again whenever the policy version changes;
- third-party scripts, which stay inert until their category is allowed;
- Google Consent Mode v2 signals on
window.dataLayer, so Tag Manager and gtag.js start denied and follow the visitor's choice; - a "Cookie settings" link in every footer, so a visitor can withdraw as easily as they agreed;
- a server-side record in
authserver.user_consentsfor signed-in visitors.
The categories are strictly necessary (always on and never asked about), preferences, analytics and marketing.
How it fits together¶
| Piece | What it does |
|---|---|
www/assets/js/pf-consent.js |
Draws the banner and the dialog, writes the pf_consent cookie, releases gated scripts and pushes Consent Mode signals. It is one file shared by all themes and app styles. |
\Pramnos\Security\CookieConsent::tag(sURL) |
Emits the <script> element, with the site's settings and translated texts in data-config. Each theme footer calls it. |
\Pramnos\Security\CookieConsent::allows('analytics') |
Answers from PHP whether this visitor agreed to a category. |
/cookieconsent |
GET returns the configuration as JSON for the SPA shell. POST /cookieconsent/record writes a signed-in visitor's choice to the consent trail. |
The banner is decided in the browser, never on the server. The page cache keys on the
URL and ignores cookies, so if markup differed per visitor, one visitor's page would be served
to another. Every visitor receives the same tag and the script reads the cookie. For the same
reason the server never sets the cookie: PageCache refuses to store a response that carries
Set-Cookie. The tag is an external file with no inline script, so it adds no CSP nonce to the
page and does not stop the page being cached.
Turning it on¶
A new project¶
init asks "Show an EU cookie consent banner? [Y/n]". Pass --cookie-consent=y|n to
answer it without the prompt.
- Yes (the default) wires everything: the script under
www/assets/js/, the tag and the "Cookie settings" link in the theme footer and the login layout, the tag in the SPA shell, and the/cookieconsentroute. Configure it under System Settings → Security → Cookie consent. - No is for a site that sets only strictly necessary cookies. It writes
'cookie_consent' => falseintoapp/app.php, and gives the SPA shell the script configured{"enabled":false}inline, so it fetches nothing.
Switching it off, or back on, in app/app.php¶
With this key, the feature is off whatever the settings say. The footer link is hidden,
allows() answers true, and the settings screen shows a note instead of the fields.
Off means everything is allowed, in the browser too. tag() still emits the script,
configured {"enabled":false}, and /cookieconsent answers the same to a SPA shell. The
script then draws nothing and treats every optional category as granted: has() answers
true, type="text/plain" scripts are released, onGrant() callbacks run, and Consent Mode
gets an all-granted update after its default. No cookie is written, so turning the banner
on later still asks every visitor. The key belongs to the deployment and is versioned with the code, so a switch on
a live server cannot add a banner to a site whose theme was never checked for one.
To turn it on, delete the line. An MVC project then works immediately, because its footer
already calls tag(). In a SPA or hybrid project, also replace the shell's
data-config="{"enabled":false}" with the data-config-url of step 3 below.
An existing project¶
-
Copy the script into the web root:
--allis required. Without it, resync only refreshes files the project already has, and it reportspf-consent.jsasskip … (not present; use --all to add). -
In your theme's
footer.php, add the tag before your own scripts andrenderJs():Then add a link that reopens the dialog wherever your footer text is:
<?php if (\Pramnos\Security\CookieConsent::enabled()): ?> <a href="#" data-consent-open> <?php echo htmlspecialchars(\Pramnos\Framework\Factory::getLanguage()->_('Cookie settings')); ?> </a> <?php endif; ?>Any element carrying
data-consent-openopens the dialog. If a page has no such element, the script adds a small fixed "Cookie settings" button so that withdrawing consent is always possible. -
SPA and hybrid projects. In the shell (
www/spa.phporwww/app.php), add this line before</body>:<script src="<?php echo htmlspecialchars($siteUrl . 'assets/js/pf-consent.js', ENT_QUOTES); ?>" data-config-url="<?php echo htmlspecialchars($siteUrl . 'cookieconsent', ENT_QUOTES); ?>" defer></script>The shell does not boot the application, so it cannot read settings itself. The script fetches them from
/cookieconsentinstead. In a pure SPA, addcookieconsentto the list of paths thatwww/.htaccesssends toindex.php(theRewriteRule ^(api|health|…)line). Otherwise the shell answers that path and the banner receives HTML where it expects JSON.
Settings¶
| Setting | Screen label | Default | Meaning |
|---|---|---|---|
cookie_consent_enabled |
Show the cookie banner | 1 |
Set it to 0 only if the site sets nothing beyond strictly necessary cookies. When it is off, nothing is drawn and every category counts as granted — allows() in PHP, has() and gated scripts in the browser. |
cookie_consent_categories |
Categories to ask about | preferences,analytics,marketing |
Any subset of the three. Unknown names are dropped, so a typo cannot become a switch that controls nothing. |
cookie_consent_policy_url |
Cookie policy page | (empty) | The page the banner links to. When empty, there is no link. |
cookie_consent_version |
Policy version | 1 |
Change it whenever the policy changes. A choice made under an older version no longer counts, so everybody is asked again. |
The constants are on \Pramnos\Security\CookieConsent (ENABLED_SETTING and the others).
Loading third-party scripts only after consent¶
To gate a script, write it with type="text/plain" and name its category in data-consent:
<script type="text/plain" data-consent="analytics" src="<?php echo sURL; ?>assets/js/analytics.js"></script>
It stays inert until the visitor allows that category. The script then replaces it with a real
<script> that keeps every other attribute (src, async, id, data-*). This happens on
the same page right after the click, and on every later page load.
A gated script must be a file. A text/plain script carries no CSP nonce, so a released
inline script is refused by the framework's policy. Move a vendor's inline snippet into a file
under www/assets/js/ and gate that file. This also keeps the page cacheable.
From JavaScript you already load, register a callback instead:
PramnosConsent.onGrant('marketing', function () {
// runs now if marketing is already allowed, otherwise once it is
});
document.addEventListener('pramnos:consent', function (event) {
console.log(event.detail.granted); // e.g. ['analytics']
});
PramnosConsent.has('analytics'); // true / false
PramnosConsent.open(); // the preferences dialog
When a visitor withdraws a category they had allowed, the page reloads. A script that has
already run cannot be unloaded, and a reload is the only way it stops running. Cookies that a
third party has already set stay until they expire. If your policy promises to delete them,
remove them by name in a pramnos:consent listener.
Google Analytics 4 (gtag.js)¶
This is the simplest compliant setup: nothing from Google loads until the visitor allows analytics.
www/assets/js/analytics.js:
window.dataLayer = window.dataLayer || [];
function gtag() { window.dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
In the theme footer, after the consent tag:
<script type="text/plain" data-consent="analytics" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX" async></script>
<script type="text/plain" data-consent="analytics" src="<?php echo sURL; ?>assets/js/analytics.js"></script>
Load order between the two does not matter, because gtag() only queues onto dataLayer.
Consent Mode, advanced setup. If you want Google's cookieless pings and modelling for
visitors who refuse, load gtag.js without type="text/plain". Place it after the consent
tag, with defer rather than async, so the script's all-denied consent default is on the
data layer before gtag.js reads it. The script then sends consent update with the visitor's
choice. Whether cookieless pings are acceptable under your reading of ePrivacy is a legal
question, not a technical one. The gated setup above avoids the question.
Google Tag Manager¶
pf-consent.js pushes three things to window.dataLayer:
gtag('consent', 'default', {…})on every page load. Everything isdeniedexceptsecurity_storage.gtag('consent', 'update', {…})with the visitor's choice. It is pushed straight away for a returning visitor, and after the click for a new one.{event: 'pramnos_consent', pramnosConsent: ['analytics', …]}alongside every update.
The categories map to Consent Mode signals as follows:
| Category | Consent Mode signals |
|---|---|
| preferences | functionality_storage, personalization_storage |
| analytics | analytics_storage |
| marketing | ad_storage, ad_user_data, ad_personalization |
Load the container after the consent tag, so that the default is set first. Put the
container snippet in www/assets/js/gtm.js:
(function (w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
var f = d.getElementsByTagName(s)[0], j = d.createElement(s);
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-XXXXXXX');
Then load it in the footer below the consent tag:
<?php echo \Pramnos\Security\CookieConsent::tag(sURL); ?>
<script src="<?php echo assetUrl('assets/js/gtm.js'); ?>" defer></script>
Both are defer, so they run in document order. The consent script pushes the all-denied
default the moment it runs, before it has any configuration, so the order holds in a SPA
shell too. There the script fetches its settings, but the default does not wait for the
fetch. Put gtm.js after the consent line in the shell, also with defer.
In the container:
- Google tags (GA4, Google Ads, Floodlight) read Consent Mode by themselves. Set the container's consent overview so they require their built-in consent.
- For any other tag, such as a Meta Pixel template, LinkedIn or Hotjar, set Additional
consent checks (for example
ad_storagefor marketing tags). Fire the tag on a Custom Event trigger namedpramnos_consent, so that it runs on the click itself and not only on the next page view. - Custom HTML tags inject inline scripts, and the framework's CSP refuses them. Use
template tags from the Community Template Gallery instead. The alternative is loosening
script-src, which weakens the whole site's policy.
If you prefer that nothing from Google loads before consent, gate gtm.js itself with
type="text/plain" data-consent="analytics". The consent commands are already queued on the
data layer when it loads.
Meta (Facebook) Pixel¶
www/assets/js/meta-pixel.js, which is Meta's snippet moved into a file:
!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', 'YOUR_PIXEL_ID');
fbq('track', 'PageView');
Load it gated on marketing:
<script type="text/plain" data-consent="marketing" src="<?php echo sURL; ?>assets/js/meta-pixel.js"></script>
Leave out the <noscript><img …></noscript> fallback that Meta's snippet includes. It fires
without JavaScript, which means it fires without consent.
Other tools¶
Every tool follows the same pattern: move its snippet into a file, gate the file on the right
category, and allow its hosts in the CSP. Examples are Hotjar or Microsoft Clarity
(analytics), LinkedIn Insight or TikTok Pixel (marketing), and a YouTube or Maps embed
(marketing or preferences, depending on the provider). An embed that is an <iframe> rather
than a script can be created from an onGrant() callback.
Allowing the hosts in the CSP¶
The framework's policy allows only 'self' for scripts, images and connections. Add each
provider under csp in app/app.php:
'csp' => [
'script-src' => [
'https://www.googletagmanager.com', // gtag.js, GTM
'https://connect.facebook.net', // Meta Pixel
],
'img-src' => [
'https://www.googletagmanager.com',
'https://*.google-analytics.com',
'https://www.facebook.com',
],
'connect-src' => [
'https://*.google-analytics.com',
'https://*.analytics.google.com',
'https://www.googletagmanager.com',
'https://www.facebook.com',
],
],
A request refused by the CSP shows up in the browser console and nowhere else. When a tag "does nothing", check the console first.
From PHP¶
use Pramnos\Security\CookieConsent;
if (CookieConsent::allows('preferences')) {
setcookie('layout', 'compact', ['expires' => time() + 86400 * 180, 'path' => '/']);
}
CookieConsent::granted(); // ['necessary', 'analytics'] — always includes necessary
Use this for behaviour and for cookies that PHP sets, and only on responses that are not
cached (a POST, or a signed-in page). Never use it to vary the markup of a page that the page
cache stores. The cache ignores cookies, so the first visitor's choice would be served to
everybody. For markup, gate in the browser with data-consent.
The consent trail¶
When a signed-in visitor chooses, the script posts the choice to /cookieconsent/record. It
authenticates the request with the page's <meta name="csrf">. The endpoint appends one row
per category the site asks about to authserver.user_consents, refusals included:
| Column | Value |
|---|---|
consent_type |
cookie:<category>@v<policy version>, for example cookie:analytics@v3 |
granted |
1 or 0 |
revoked_at |
set on a refusal |
legal_basis |
consent |
ip_address |
the visitor's address |
An anonymous visitor has no row, because the table is keyed by user. Their record is the cookie, which carries the policy version and the time of the choice. A SPA page has no CSRF meta, so its choices are kept in the cookie only. The row is written after the cookie, so a failure to write it is logged and never makes the banner ask again.
Translating and styling¶
The banner's texts come from CookieConsent::config() and go through the language catalogue,
keyed by their English wording. Add them to app/language/el.php (or any other language):
'We use cookies' => 'Χρησιμοποιούμε cookies',
'Accept all' => 'Αποδοχή όλων',
'Reject all' => 'Απόρριψη όλων',
'Customise' => 'Προσαρμογή',
'Save choices' => 'Αποθήκευση επιλογών',
'Cookie settings'=> 'Ρυθμίσεις cookies',
'Cookie policy' => 'Πολιτική cookies',
// …and the category labels and descriptions, which you can find in CookieConsent::config()
The banner brings its own styles as a constructed stylesheet. A constructed stylesheet is not
an inline <style> element, so the CSP does not block it. It follows prefers-color-scheme.
Constructed stylesheets come after the page's own stylesheets in the cascade, so to recolour
the banner, override its custom properties with a slightly more specific selector in your theme
CSS:
The available properties are --pf-c-bg, --pf-c-fg, --pf-c-muted, --pf-c-border,
--pf-c-accent and --pf-c-accent-fg. The element classes are .pf-consent-banner,
.pf-consent-dialog, .pf-consent-row and .pf-consent-reopen.
Troubleshooting¶
| Symptom | Cause |
|---|---|
| No banner at all | Check, in order: the setting is 0, or app.php says 'cookie_consent' => false; the footer does not call CookieConsent::tag(); assets/js/pf-consent.js is missing (run project:resync --js --all); in a SPA, /cookieconsent is answered by the shell (see the .htaccess step above). |
| The banner comes back on every page | The cookie is not being stored. Check that the site is not on a different host or path than the one the banner loaded on. A Secure cookie on plain http is dropped, and the script adds Secure only on https. |
| A gated script never runs | Its category is not among the ones the site asks about, it is inline instead of a file, or the CSP refuses its host. |
| GTM tags fire before consent | The container loads before the consent tag. Move it below the tag, with defer. |