Search by

vaersaagod / consentmate

vaersaagod

No means no, mate!

Package info

github.com/vaersaagod/consentmate

Documentation

Type:craft-plugin

pkg:composer/vaersaagod/consentmate

Statistics

Installs: 11

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.4 2026-11-04 14:32 UTC

This package is auto-updated.

Last update: 2026-11-04 14:33:17 UTC


README

No means no, mate!

ConsentMate is a Craft-native consent manager, a replacement for Cookiebot, Cookie Information and the like. The developer sets up the consent categories, Google Consent Mode and lifetimes in a config file. The client adds tracking services (with their scripts and cookies) and edits the banner copy in the control panel, per site.

It's built for statically cached sites. Every visitor gets the same HTML, consent is read and applied in the browser, and nothing ConsentMate outputs sets a cookie or starts a session.

Requirements

This plugin requires Craft CMS 5.6.0 or later, and PHP 8.2 or later.

Price, license and support

The plugin is released under the Craft license and could be subject to license fees. It's made for Værsågod and friends, and no support is given. Submitted issues are resolved if it scratches an itch.

Not legal advice

ConsentMate is a tool. The client is the data controller and is responsible for what the site does with personal data, for the wording of the banner, and for declaring every cookie and service accurately. Have someone with legal knowledge look over the copy and the setup before a site launches.

Installation

  1. Install with composer via composer require vaersaagod/consentmate from your project directory.

  2. Install the plugin in the Craft Control Panel under Settings → Plugins, or from the command line via ./craft plugin/install consentmate.

  3. Output ConsentMate in <head>, before any other script:

    <head>
        <meta charset="utf-8">
        {% hook 'consentmateHead' %}
        ...

    This outputs a small inline script, the head code of services, and the runtime and stylesheet. The banner is added at the start of <body> automatically.

  4. Add a link that reopens the consent settings, for example in the footer. Withdrawing consent must be as easy as giving it:

    <button type="button" data-consentmate-open>{{ craft.consentmate.texts().floatingButtonLabel }}</button>

    Or turn on floatingButton in the config.

  5. Gate embeds and scripts in your templates (see below), and put the cookie declaration on the privacy or cookie policy page.

  6. In the CP, under ConsentMate → Services, add the services the site uses. Under Texts, fill in the company name (the data controller), check the copy, and add the link to the privacy policy.

How it works

  • Nothing optional runs before consent. Gated markup is output inside <template data-consentmate="category"> elements. Browsers never run or load what's inside a <template>, so without consent (or without JavaScript) nothing happens. When a category is granted, the runtime inserts the markup and runs its scripts in order.
  • The consent cookie (consentmate) holds a consent ID, the granted categories, the revision and a timestamp. The runtime writes it right away, then posts the choice to the server, which logs it and sets the same cookie again from the server. Safari caps cookies set from JavaScript at 7 days; the server copy keeps it for its full lifetime.
  • Revisions. Consent is given for a revision. Enabling a new service, or moving one to another category, bumps the revision (configurable), and everyone is asked again. Clients can also bump it with "Ask everyone again" on the Services page.
  • Proof of consent. Every choice is logged with the consent ID, the granted categories, the site, the page, the language and a snapshot of the texts and services the visitor was shown. Visitors can find their consent ID in the settings dialog and on the declaration. Log rows are deleted after log.retention (24 months by default) by php craft consentmate/purge. Craft's garbage collection does the same, but only on a small share of requests, so run the command daily from cron.
  • Withdrawal and refusal. When a choice is saved, the first-party cookies and storage declared on services in categories that aren't granted are deleted, also ones left from an earlier consent. When a category that was on is turned off, the page reloads so scripts that already ran are gone.
  • Static caching. CacheMate and Blitz are purged automatically when services, texts or the revision change. For other caches, listen to Caches::EVENT_AFTER_CHANGE.

Gating markup in templates

Use the consent tag around anything that sets cookies or contacts a third party, like video embeds, maps and widgets:

{% consent 'marketing' %}
    <script src="https://example.com/widget.js"></script>
{% endconsent %}

{# With a placeholder that explains why the content is missing, and lets the visitor allow it #}
{% consent 'marketing' with { placeholder: true, provider: 'YouTube' } %}
    <iframe src="https://www.youtube-nocookie.com/embed/{{ videoId }}" title="{{ title }}"></iframe>
{% endconsent %}

Or the filter, for HTML you already have in a variable (like the embed code from Toolmate's getVideoEmbed()):

{% set video = getVideoEmbed(videoUrl) %}
{{ video.embedCode|consent('marketing', { placeholder: true, provider: video.provider }) }}

The placeholder is a box with the placeholder text from the CP, an "Allow and show" button that grants the category, and a link to the settings. Pass a template path as placeholder to use your own. The wrapper around gated markup uses display: contents, so CSS like .video iframe keeps working.

Markup in a required category (necessary) isn't gated.

Gating by service

Instead of a category, you can name a preset. The markup then follows the category of the service made from that preset, and the placeholder uses the service's name:

{% consent 'youtube' with { placeholder: true } %}
    <iframe src="https://www.youtube-nocookie.com/embed/{{ videoId }}" title="{{ title }}"></iframe>
{% endconsent %}

{{ video.embedCode|consent('vimeo', { placeholder: true }) }}

If the client moves YouTube to another category, the template doesn't need to change. When no service on the site is made from the preset, the preset's default category is used, and a warning is logged in dev mode.

Toolmate's getVideoEmbed() returns the provider as "YouTube" or "Vimeo", so video.provider|lower is the preset handle. Map other providers to a category: a handle that's neither a category nor a preset is never shown.

{% set provider = video.provider|lower %}
{{ video.embedCode|consent(provider in ['youtube', 'vimeo'] ? provider : 'marketing', { placeholder: true }) }}

Services (CP)

A service is a tool that needs consent: Google Analytics, Meta Pixel, Hotjar, YouTube. Each service has:

  • a category
  • a provider, a purpose, a privacy policy link and data transfers outside the EU/EEA (the last three per site)
  • the cookies and other storage it sets, with type, who sets it, and duration
  • head code and body code, pasted as the provider gives it, run once the visitor consents to the category

A service without code is a declaration only, for example Google Analytics when it's loaded through Google Tag Manager. Services can be enabled and disabled per site.

Snippets that use document.write() can't be activated after the page has loaded. Current snippets from the big vendors don't use it.

Presets

"New service" lists presets for common services, grouped by category. A service made from a preset comes with its name, provider, purpose (in each site's language), privacy policy, data transfers and cookies filled in.

Presets for tracking tools ask for an ID, like the Google Analytics measurement ID, and ConsentMate writes the tracking code. That code is updated along with the plugin. To use your own code instead, paste the full tracking code on the Code tab; pasted code replaces the generated code, so the tool never loads twice. Leave both empty to only declare the service, for example when it's loaded through Google Tag Manager.

Presets for embeds and bundled scripts (YouTube, Vimeo, maps) have no code. Gate the markup in your templates by the preset's handle (see Gating by service).

The built-in presets:

Preset Handle Default category Asks for
Google Analytics googleAnalytics Statistics Measurement ID
Plausible plausible Statistics Script ID (pa-…)
Fathom fathom Statistics Site ID
Vimeo vimeo Statistics
Meta Pixel metaPixel Marketing Pixel ID
LinkedIn Insight Tag linkedinInsight Marketing Partner ID
HubSpot hubspot Marketing Hub ID
YouTube youtube Marketing
Spotify spotify Marketing
Facebook facebook Marketing
Instagram instagram Marketing
Google Maps googleMaps Preferences
Mapbox mapbox Preferences
Typeform typeform Preferences
Google reCAPTCHA recaptcha Strictly necessary
Adobe Fonts adobeFonts Strictly necessary

The cookie lists were checked against the vendors' documentation and live tests in October 2026, and rechecked in November 2026. When a vendor changes its cookies, the preset is updated in a plugin release, and services made from it follow (see Keeping cookies up to date). Google Tag Manager isn't a preset: it's set up in the config (googleTagManager), since it loads for more than one category.

Add your own presets, change built-in ones or remove them in the config. Each one takes the same keys as the files in src/presets:

'presets' => [
    // Change a built-in preset's default category
    'mapbox' => ['category' => 'necessary'],
    // Remove a built-in preset
    'adobeFonts' => false,
    // Add a preset
    'acmeChat' => [
        'name' => 'Acme Chat',
        'category' => 'preferences',
        'provider' => 'Acme AS',
        'description' => 'Lets you chat with us.',
        'cookies' => [['name' => 'acme_*', 'type' => 'cookie', 'party' => 'first', 'duration' => 30, 'unit' => 'days']],
        'fields' => ['widgetId' => ['label' => 'Widget ID', 'pattern' => '/^[a-z0-9]{8}$/']],
        'bodyCode' => '<script src="https://chat.acme.no/{widgetId}.js" async></script>',
    ],
],

Field patterns are required and should be strict: the value is put into the code as is.

Keeping cookies up to date

A service remembers the cookies its preset gave it. When the preset's cookies change, the service's cookie list follows, without touching the client's own changes:

  • A cookie the client hasn't changed follows the preset: it's updated, or removed if the preset dropped it.
  • A cookie the client changed or deleted stays as the client left it.
  • A cookie new to the preset is added, unless the service already has one by that name.
  • Cookies the client added are never touched.

Only the cookies are updated. The name, purpose, privacy policy and data transfers stay as they are. The service's Cookies field says what the last update changed.

Visitors aren't asked again, and the consent log keeps a snapshot of what each visitor was shown. If a change matters to visitors, for example a cookie that shares data with someone new, use "Ask everyone again" on the Services page.

Updates to the built-in presets are applied by the plugin's migrations when you update it. After changing your own presets in the config, run:

php craft consentmate/presets/sync --dry-run   # what would change
php craft consentmate/presets/sync

Moving a site over

Many sites keep their tracking tags in a "Scripts" global set or in the layout. Move each tag to a service:

  1. Find the tag's preset under "New service" and fill in its ID. If there's no preset, create a blank service and paste the tag's code.
  2. Pick the category, and check the cookies.
  3. Remove the tag from the global set or template.

Then check the cookie report after launch: it flags tags you missed.

The cookie report

ConsentMate keeps an eye on the declaration for you. When visitors save their consent, their browser sends the cookies this website has set, and ConsentMate compares them with the first-party cookies the services declare. Under ConsentMate → Cookie report it lists:

  • Set without consent: declared cookies that were present while their category wasn't granted. Something sets them before consent, usually a script added straight to a template instead of to a service or inside {% consent %}. This is the kind of problem regulators fine for, so fix it, then clear the report.
  • Not declared: cookies no service declares. Add each one to the service that sets it (straight from the report), or ignore it. When a cookie belongs to a preset, the report suggests it: "Create service" makes a service from the preset, or the existing one is preselected.

The nav item shows a badge with the number of open issues. Reports not seen for 90 days are deleted by php craft consentmate/purge (or when Craft collects garbage), and ignored ones are kept so they stay ignored. A site keeps at most 200 reports of each kind, so made-up cookie names sent to the public save endpoint can't fill the table or crowd out cookies set without consent. The report says when a site is full; clear the ones you've handled.

A visitor whose consent has expired can still have cookies from it, and they're reported as set without consent when that visitor makes a new choice. A report with only a few hits can be that. A script that runs before consent shows up on most visits.

Cookies set by other websites (like YouTube's) are never sent to this website, so they can't be checked; declare them by hand. Craft's control panel cookies are left out. Add others that aren't set for visitors (like a CDN's) to cookieReport.ignore in the config.

The cookie declaration

{{ craft.consentmate.declaration() }}
{{ craft.consentmate.declaration({ headingLevel: 3 }) }}

Renders every category with its services and cookies, plus the visitor's current consent and buttons to change or withdraw it. Override the template with declarationTemplate, or build your own from craft.consentmate.categories().

Google Consent Mode and Google Tag Manager

Consent Mode v2 is on by default: the bootstrap sets everything to denied before any tag loads, and sends an update when the visitor chooses. The categories map to consent types in the config.

Let ConsentMate load Google Tag Manager (or a Google tag) itself:

'googleTagManager' => [
    'id' => App::env('GTM_ID'),
    'loadOn' => ['statistics', 'marketing'],
],

In the default basic mode, the container only loads once one of the loadOn categories is granted. A German court (VG Hannover, 2025) found that loading Google Tag Manager itself needs consent. Advanced mode ('googleConsentMode' => ['mode' => 'advanced']) loads it right away with everything denied; only use it if you know you want that.

ConsentMate controls whether the container loads and what consent it reports. The tags inside the container need consent checks of their own: Google's tags read Consent Mode, and other tags need "Additional consent checks" in GTM.

Sites that use Google Ads must tell visitors that data is shared with Google for ads personalization. The default banner text does, with a link to Google's page on how it uses data.

JavaScript API

The bootstrap sets up window.ConsentMate before any other script runs:

ConsentMate.has('statistics');          // true or false
ConsentMate.on('change', (state) => {}); // also 'ready'
ConsentMate.showSettings();
ConsentMate.show();                     // the banner
ConsentMate.acceptAll();
ConsentMate.rejectAll();
ConsentMate.accept(['statistics']);
ConsentMate.getState();                 // { id, given, granted, timestamp, gpc }

document.addEventListener('consentmate:change', (event) => console.log(event.detail));

Every choice also pushes a consentmate_update event to the dataLayer.

Customizing the banner

Theme the default banner with CSS custom properties:

:root {
    --consentmate-accent: #0b5;
    --consentmate-accent-fg: #fff;
    --consentmate-radius: 0;
    --consentmate-font: "Inter", sans-serif;
}

Override single rules with class selectors in your own CSS, which loads after the consentmateHead hook and so wins at the same specificity:

.consentmate-button { border-width: 1px; background: transparent; color: inherit; }
.consentmate-heading { font-size: 1.25rem; }

The stylesheet resets the elements in ConsentMate's markup (p, h2, li, label, button and so on) with selectors that count as one class. The site's global element styles, like p { font-size: 24px }, don't reach the banner, while any class selector still overrides it. The toggles use .consentmate-switch[type="checkbox"], so global checkbox styles don't reach them either; restyle them with that selector. In the cookie declaration, only the service boxes and buttons are styled by ConsentMate. Its headings and paragraphs look like the rest of the page.

Or copy src/templates/_frontend/banner.twig into your project, point bannerTemplate at it, and set includeCss to false to style it from scratch. Keep the data-consentmate-* attributes; the comment at the top of the template lists them.

Keep the Accept all, Reject all and Settings buttons on the first layer, and make them equally prominent. Regulators treat a less visible reject option as invalid consent.

Content Security Policy

ConsentMate's scripts get Toolmate's nonce automatically when Toolmate's CSP header is on. Otherwise set cspNonce to a string or a callable. Scripts activated after consent are created by the runtime, so a nonce-based policy needs 'strict-dynamic' in script-src.

Configuration

ConsentMate is configured in config/consentmate.php. See src/config.php for every setting, with defaults:

Setting Default
pluginName 'ConsentMate' The name editors see in the control panel, e.g. 'Consent'. Translated with the consentmate category, so 'Consent' shows as "Samtykke" in a Norwegian CP
enabled true A boolean, or per site handle with a '*' fallback
categories necessary, preferences, statistics, marketing Handles, required, and Google Consent Mode types
consentLifetime 'P12M' How long consent is kept
refusalLifetime 'P6M' How long a refusal is kept before asking again
cookieName / cookieDomain 'consentmate' / null
googleConsentMode enabled, basic mode, waitForUpdate, regions, urlPassthrough, adsDataRedaction
googleTagManager / googleTag none id and loadOn
vendorSignals off microsoft (UET consent mode), meta (Pixel consent API)
gpc enabled, marketing Global Privacy Control: "Accept all" leaves these categories off
bannerTemplate, declarationTemplate, placeholderTemplate null Your own templates
includeCss true
floatingButton false A button that reopens the settings
reloadOnWithdraw true
renewOnNewService true Ask everyone again when a service is enabled or moved
log enabled, 'P24M' retention, userAgent, ipHash (a keyed hash, never the IP)
cspNonce null Toolmate's nonce, a string, or a callable
necessaryCookies ConsentMate's and Craft's cookies Declared under "This website"
rateLimit 30 Consent saves per visitor per minute
cookieReport enabled, 'P90D' retention, ignore (cookie names with * wildcards)
presets [] Add presets, change built-in ones, or remove them with false

Permissions

  • Manage services: add and edit services, ask everyone again, and handle the cookie report
  • Edit texts: edit the banner and category copy
  • View the consent log

Setup checklist

  • {% hook 'consentmateHead' %} is first in <head>, before any other script, on every page template.
  • No tracking script is hardcoded in templates outside {% consent %}. Check in a private window with dev tools open (Application → Cookies, and the Network tab) that nothing optional loads before a choice.
  • Every service the site uses is added in the CP, with its cookies, provider and purpose, and enabled on the right sites.
  • Embeds (video, maps, social widgets) are gated with a placeholder.
  • There's a "Cookie settings" link on every page, or the floating button.
  • The cookie declaration is on the privacy or cookie policy page, and the Texts page links to that page.
  • The Texts are checked in every site language, and the company name is filled in.
  • Inside Google Tag Manager, every tag has the right consent checks.
  • Static caches are purged when services change (CacheMate and Blitz are automatic).
  • php craft consentmate/purge runs daily from cron, so the log retention holds.
  • After launch, the cookie report is empty, or every item in it is handled.

What it doesn't do (yet)

  • No crawling cookie scanner. The cookie report only sees first-party cookies, and only from visitors who save their consent.
  • No regional modes. Opt-in is used everywhere, which also satisfies opt-out regimes like the UK's statistics exemption, Switzerland and the US states.
  • No IAB TCF. It's only needed by publishers selling programmatic ads.
  • No per-service toggles. Consent is per category.

Credits

The cookie data in the presets is curated by hand from the vendors' own documentation, cross-checked against the Open Cookie Database (Apache-2.0).