belisoful / prado-privacy
Privacy compliance for PRADO: consent management, data subject rights, retention and records of processing (GDPR, UK GDPR/PECR, ePrivacy, CCPA/CPRA).
Package info
github.com/belisoful/prado-privacy
Type:prado4-extension
pkg:composer/belisoful/prado-privacy
Requires
- php: >=8.2.0
- pradosoft/prado: ^4.4@dev
- pradosoft/prado-activerecord: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- maxmind-db/reader: ^1.11
- phpstan/phpstan: ^2.1.22
- phpunit/phpunit: ^10
Suggests
- ext-pdo_sqlite: The consent log, the database consent store, the privacy request log and retention purges without a configured database connection.
- belisoful/ganalytics: Google Analytics 4 with Consent Mode driven by TConsentModule (its GAnalyticsPrivacyConsentProvider).
- maxmind-db/reader: Locating visitors from a local MaxMind DB file (GeoLite2, GeoIP2, DB-IP Lite, IPinfo, IP2Location) with TMaxMindDbLocator.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 07:31:56 UTC
README
Privacy compliance for the PRADO PHP Framework (version 4.4+), implemented as a PRADO 4 extension: consent management (GDPR, UK GDPR and PECR, the ePrivacy Directive, CCPA/CPRA and the US state laws that recognize Global Privacy Control), data subject rights (export, erasure, rectification), retention, and records of processing.
The class names carry no vendor prefix (TConsentModule, not PrivacyConsentModule), so parts
of the package can be proposed to pradosoft/prado.
Status: in development, feature-complete for the first release. Consent management, data subject rights, retention, records of processing, and the Google Analytics binding (in belisoful/ganalytics). The design is recorded in agents/working/DESIGN_2026-09-29.md.
This package supplies the mechanism. The site owner remains responsible for the policy text, the categories a site uses, the legal basis of each processing activity, and the advice of counsel.
Requirements
| Requirement | Scope | Purpose |
|---|---|---|
| PHP 8.2 – 8.5 | required | The runtime. CI runs every minor from 8.2 through 8.5 |
PRADO Framework ^4.4 |
required | Modules, controls and ActiveControls, THttpCookie, TSecurityManager, TCronModule, the clock seam, the Composer extra.prado extension mechanism |
pradosoft/prado-activerecord ^1.0 |
required | TActiveRecord, for the consent log, the database consent store and the request log; it left the framework core in 4.4 |
ext-pdo_sqlite |
optional | The consent log, the database consent store, the request log and retention purges without a configured database connection |
maxmind-db/reader |
optional | Locating visitors from a local MaxMind DB file |
Installation
composer require belisoful/prado-privacy
pradosoft/prado ^4.4@dev resolves to the framework's development branch, so the root project
needs "minimum-stability": "dev" and "prefer-stable": true until PRADO 4.4 is released.
Load the extension by package name; the module class comes from extra.prado.bootstrap:
<modules> <module id="belisoful/prado-privacy" Version="2026-09" /> </modules>
Consent management
TConsentModule holds the visitor's consent per category, and the controls ask for it, change it
and gate content by it.
<modules> <module id="belisoful/prado-privacy" Version="2026-09" LogEnabled="true"> <category Name="video" Title="Video" Description="Embedded YouTube and Vimeo players." /> </module> </modules>
<com:TConsentBanner PolicyUrl="/privacy" /> <com:TConsentPreferences /> <com:TConsentGate Category="video"> <iframe src="https://www.youtube-nocookie.com/embed/…"></iframe> <prop:DeniedTemplate><p>This video needs your consent. <com:TConsentLink Text="Allow video" /></p></prop:DeniedTemplate> </com:TConsentGate> <footer><com:TConsentLink PreferencesUrl="/privacy-settings" /> <com:TConsentLink Mode="optout" /></footer>
$consent = TConsentModule::findModule(); if ($consent->isGranted('analytics')) { /* register the analytics script */ } $consent->attachEventHandler('onConsentChanged', fn ($sender, $param) => /* $param->getChanged() */ null);
Categories and states
| Category | Default | Meaning |
|---|---|---|
necessary |
always granted | storage the site needs: the session, security, this consent choice |
analytics |
asked | measuring visits |
marketing |
asked | advertising, ad measurement, sharing with ad partners (the "sale or sharing" of CCPA) |
functional |
asked | embeds and features: maps, video, chat |
personalization |
asked | remembering preferences to adapt content |
getConsent() maps each category to granted, denied or undecided; isGranted() treats
undecided as not granted. A <category> element adds a category or retitles a built-in one.
Changing the policy calls for a new Version: a choice made under another version counts as
undecided, and the visitor is asked again. In opt-in regions a category added without a new
Version is undecided on its own, and the banner asks again until a choice covers it.
Outside opt-in regions an opt-out stands: the denials of a choice made under an older version, or
expired while the store still holds it, still apply, and withdraw() stores every category denied
instead of clearing the choice (which there would mean granted). A region known only by its
country (US without a state) takes the strictest mode any of its subdivisions in
RequireConsentFor or OptOutFor has. EU includes the outermost regions and Åland, which
geolocation reports under their own codes (GF, GP, MQ, YT, RE, MF, AX).
Module properties
| Property | Default | Purpose |
|---|---|---|
Version |
1 |
the policy version choices are made under |
Expires |
180 |
days a choice lasts (1 to 395), for acceptance and rejection alike; lowering it does not shorten choices already stored |
Store |
TCookieConsentStore |
where the choice is kept; <store class="…"/>, a module id, or an instance |
LogEnabled |
false |
append every choice to the consent log |
Log |
TDbConsentLog |
the log; <log class="…" ConnectionID="db"/> |
LogUserAgent |
false |
also log a hash of the user agent |
RegionResolver |
TScopeAllRegionResolver |
where the visitor is; <resolver class="…"/> or a module id |
RequireConsentFor |
EU, EEA, UK, CH |
regions asked before non-necessary storage (opt-in) |
OptOutFor |
US-GPC |
regions with an opt-out regime (California and the GPC states) |
HonorGpc |
everywhere |
where Global Privacy Control applies: everywhere, required, off |
GpcCategories |
marketing |
the categories GPC denies |
The GDPR asks a controller to be able to demonstrate consent (Art. 7(1)). A site in scope sets
LogEnabled="true". Without a ConnectionID, the log writes to protected/runtime/privacy.db,
which needs pdo_sqlite.
Stores
| Store | Keeps the choice in | Notes |
|---|---|---|
TCookieConsentStore |
one cookie, prado_consent |
signed with TSecurityManager::hashCookieData() (the CookieValidationKey); HttpOnly, SameSite=Lax, Secure on https |
TSessionConsentStore |
the session | lasts as long as the session; reading never starts one |
TDbConsentStore |
privacy_consent_state |
the browser holds only the consent id; a logged-in user gets their choice on another device, and on a shared device not another user's |
The consent cookie holds a choice the visitor asked to be remembered, so it is strictly necessary storage and needs no consent itself.
Set a fixed ValidationKey on TSecurityManager, the same on every server: without one, each
server generates its own and keeps it in its runtime state. TPrivacyHash derives its key from
it for the hashes of IP addresses, user names and user agents (the consent log, the database
store, the data subject requests) and the IP cache keys; the cookie signature uses the
CookieValidationKey, derived from it unless set. Rotating
ValidationKey breaks the links between hashes made before and after: a user's earlier log
entries and stored choices no longer match their name. Rotating either key makes the consent
cookies unreadable, so visitors are asked again.
Regions
| Mode | Regions | Before a choice | Banner |
|---|---|---|---|
| opt-in | RequireConsentFor, and every visitor whose region is unknown |
undecided | shown |
| opt-out | OptOutFor |
granted | none; TConsentLink Mode="optout" renders |
| none | anywhere else | granted | none |
The default resolver places nobody, so every visitor is in opt-in mode. That is the strictest reading, and right for a site that serves Europe without locating visitors. To place visitors:
| Resolver | Source | Cost |
|---|---|---|
THeaderRegionResolver |
a CDN country header (CF-IPCountry, CloudFront-Viewer-Country, subdivision headers) |
none; trust it only when the CDN sets it |
TIpRegionResolver + TMaxMindDbLocator |
a local MaxMind DB file: GeoLite2 (free), GeoIP2, DB-IP Lite (free), IPinfo, IP2Location | a local lookup; needs maxmind-db/reader |
TIpRegionResolver + THttpIpLocator |
a web API; presets for MaxMind, IPinfo, ipdata, ipgeolocation.io, Ipregistry, ipapi.co, ipstack, IP2Location.io, Abstract, BigDataCloud and DB-IP, or any JSON API by Url and dot paths |
a request per visitor, cached |
TChainRegionResolver |
the first of several that answers | |
your own IRegionResolver or IIpLocator |
anything |
<module id="geo" class="belisoful\Privacy\Consent\Region\TIpRegionResolver" TrustedProxies="10.0.0.0/8"> <locator class="belisoful\Privacy\Consent\Region\THttpIpLocator" Service="ipinfo" ApiKey="…" /> </module> <module id="belisoful/prado-privacy" RegionResolver="geo" />
TIpRegionResolver reads X-Forwarded-For, or without it the for= parameters of Forwarded
(RFC 7239), only behind a TrustedProxies peer, and takes the rightmost hop that is not a trusted
proxy. Ports and brackets are removed from each hop, and an IPv4-mapped IPv6 address
(::ffff:10.0.0.1, as a dual-stack listener reports it) is matched and validated as IPv4, so write
the trusted ranges in IPv4. The resolver skips private and reserved addresses, caches each answer
(a day) and each miss (an hour) under an HMAC of the address, in keys that carry its module id,
and fails closed: a visitor it cannot place is asked for consent. A failure of the locator or the
cache (a missing or corrupt .mmdb file, a timeout, a cache error) is logged as a warning without
the address and never reaches the page; only a missing Locator is thrown. The web API transport follows no
redirects, bounds the whole request by Timeout and refuses an answer above 64 KiB.
A lookup through a web API sends the visitor's IP address to the provider before any consent.
Prefer a CDN header or a local database, and list the provider as a recipient in the records of
processing. Check each provider's current terms, quotas and endpoints; the presets reflect them as
of this release. The ipstack preset uses https, which ipstack's free plan does not offer: it
needs a paid plan, or a Url with http://, which sends the address and the key unencrypted.
TChainRegionResolver treats a country without its subdivision (US, from CF-IPCountry or a
country-only database) as provisional when a region group lists subdivisions of it, and asks the
next resolvers for the state; the country is the answer when none gives one. Behaviors can override the region with dyResolveRegion and the consent with dyConsent.
Global Privacy Control
A Sec-GPC: 1 request header is honored as follows (HonorGpc="everywhere"):
| Region | Effect |
|---|---|
| opt-out (California; Colorado, Connecticut, Delaware, Maryland, Minnesota, Montana, Nebraska, New Hampshire, New Jersey, Oregon, Texas) | marketing is denied, overriding a banner grant or no choice. CCPA regulations §7025 make the signal a valid opt-out of sale and sharing. A grant the visitor makes per category in the preferences stands (§7025(c)(3)) |
| opt-in (EU, EEA, UK, CH, unknown) | marketing starts denied and the banner says so; Accept all leaves it denied; only a grant in the preferences overrides the signal |
| anywhere else | as for opt-out |
HonorGpc="required" applies the signal in opt-out regions only; off ignores it. The US-GPC
list follows the state laws in force at release; check it against current law, and extend it with
TRegionGroups::defineGroup() or region codes.
Controls
| Control | Renders | Actions |
|---|---|---|
TConsentBanner |
while consent is required and undecided | Reject all, Accept all (the same element and class: equal prominence), Customize (a link to PreferencesUrl on a page without preferences) |
TConsentPreferences |
when opened, or always with AlwaysVisible |
a checkbox per category (required ones disabled), Reject all, Accept all, Save, Close; all recorded with source preferences |
TConsentLink |
always; Mode="optout" only in opt-out regions |
opens the preferences; PreferencesUrl is followed without JavaScript, and on a page without preferences |
TConsentGate |
its content while Category is granted, its DeniedTemplate otherwise; denied content is not pre-rendered, so it registers no script |
Every action works by ActiveControl callback (the banner, the preferences, the gates and the links
re-render in place) and by postback without JavaScript. The packaged stylesheet
(assets/consent.css, light and dark) is published unless DefaultStyle="false". Texts are
properties (Message, AcceptText, RejectText, …) passed through Prado::localize().
Withdrawing must be as easy as consenting (Art. 7(3)): put a TConsentLink in every page's footer.
On a page without a TConsentPreferences, TConsentLink and the banner's Customize are plain links
to their PreferencesUrl, a page holding <com:TConsentPreferences AlwaysVisible="true" />; with
neither, rendering them throws a TConfigurationException. PolicyUrl and PreferencesUrl take
http, https or relative URLs only. Category titles and TConsentLink's text are HTML-encoded
(Encode="false" on the link allows trusted markup).
After a callback the focus moves with the change (assets/consent.js, loaded through the client
script manager, so it carries the page's CSP nonce): opening the preferences focuses their heading;
Save, Accept all, Reject all or Close there returns it to the link or button that opened them, or
to the start of the page; Accept all or Reject all in the banner focuses the page's last
TConsentLink.
The banner is fixed to the bottom of the viewport. So that it does not cover a focused element
(WCAG 2.4.11), the stylesheet sets scroll-padding-bottom on html while the banner shows, from
--prado-consent-scroll-padding (default 14rem). Set it to the banner's height when the texts
make it taller or shorter:
:root { --prado-consent-scroll-padding: 18rem; }
Consent log and schema
TDbConsentLog appends a row per choice to privacy_consent_log: the consent id (the link to
the visitor's cookie), time, policy version, the full decision, the source (banner,
preferences, api, withdraw), the region, whether GPC was present, and HMAC-SHA256 hashes of
the IP address, the user name and (with LogUserAgent) the user agent. Nothing identifies a
person in clear. config/schema/ holds the DDL for sqlite, MySQL and PostgreSQL.
With AutoCreateTables (the default) a component creates a missing table on first use: each
process reads the connection's table names once and runs DDL only for a table that is missing.
On MySQL a DDL statement commits the open transaction, and a database user without CREATE
cannot run it. For a production MySQL or PostgreSQL database, create the tables from
config/schema/<driver>.sql and set AutoCreateTables="false" on the log, the store and the
request log.
Without a ConnectionID the tables live in protected/runtime/privacy.db, which sqlite creates
with the permissions of the process umask. Restrict the file to the web server's user in
production.
Data subject rights
TPrivacyModule answers access, portability, erasure and rectification requests (GDPR Art. 15–17
and 20; CCPA/CPRA §1798.100–.106) across every module that holds personal data, and records each
request and its outcome.
<module id="rights" class="belisoful\Privacy\Rights\TPrivacyModule" ResponsePeriod="P1M"> <provider class="belisoful\Privacy\Rights\TUserPersonalDataProvider" /> <provider class="belisoful\Privacy\Rights\TConsentPersonalDataProvider" /> <provider class="belisoful\Privacy\Rights\TSessionPersonalDataProvider" /> </module>
The session provider comes last: erasing it destroys the session, and with TSessionConsentStore
the consent provider writes its withdrawal to that session first.
// The application verifies the requester first; the module does not. $subject = new TDataSubject('alice', 'alice@example.com'); $export = $rights->export($subject, 'self'); // Art. 15: status, errors, request history, data $json = $rights->exportJson($subject, 'self'); // Art. 20: the same, as JSON $results = $rights->erase($subject, 'operator:dpo'); // Art. 17: TErasureResult per provider $rights->rectify($subject, ['user' => ['email' => 'new@example.com']]); // Art. 16 // A request received by post counts toward the deadline from the day it arrives: $id = $rights->openRequest('erase', $subject, 'letter'); $rights->extendRequest($id, 'P2M', 'many requests this month'); // Art. 12(3) $results = $rights->erase($subject, null, $id); // answers request $id instead of opening another $late = $rights->getOverdueRequests();
A module joins by implementing IPersonalDataProvider: getPersonalDataName(),
exportPersonalData(), erasePersonalData() (returning a TErasureResult: counts erased, counts
retained with the legal reason, errors) and rectifyPersonalData(). Loaded modules that implement
it are discovered (AutoDiscover); others are listed with <provider>.
A provider fails when it throws, when its TErasureResult has errors, or when
rectifyPersonalData() returns false. A failure does not stop the other providers. The request
is completed when no provider failed, failed when every provider failed, and partial
otherwise. The export carries that status, an errors map (provider => exception class, with
the provider's data entry null) and requests, the subject's request history including this
request. rectify() returns false for a provider that threw. The exception class goes to the
application log and the request record; the exception message, which may hold personal data,
goes to the application log only in Debug mode.
| Built-in provider | Export | Erase |
|---|---|---|
TUserPersonalDataProvider |
the user's name, roles and TUser state from the user manager; onExportUser adds fields |
raises onEraseUser for the application to delete the user (PRADO has no user-deletion API); without a handler, or for a TUserManager user defined in configuration, the user is reported as retained |
TSessionPersonalDataProvider |
the current session, when the subject is the logged-in requester | destroys it; other visitors' sessions are unreachable and reported as such |
TConsentPersonalDataProvider |
the current choice, TDbConsentStore rows, and the subject's consent log entries |
withdraws the choice and deletes the rows; the log entries are retained as proof of consent (Art. 7(1), 17(3)(b)) until retention removes them; outside opt-in the stored opt-out is retained so it is still honored |
Every request goes to privacy_request (TDbPrivacyRequestLog, the runtime sqlite database
unless <requestlog ConnectionID="…"/>): type, the subject's hash, who asked (at most 255
characters), when, the due date, status and the outcome per provider, without personal data.
onPrivacyRequest and onPrivacyRequestComplete bracket each request.
| Deadline | Law | Configuration |
|---|---|---|
| one month, extendable by two further months | GDPR Art. 12(3) | ResponsePeriod="P1M" (the default), extendRequest($id, 'P2M', $reason) |
| 45 days, extendable by 45 more | CCPA §1798.130(a)(2) | ResponsePeriod="P45D", extendRequest($id, 'P45D', $reason) |
ResponsePeriod is an ISO 8601 duration counted in calendar terms in UTC: a request made on
1 February is due on 1 March, and one made on 31 January is due on the last day of February.
extendRequest() moves the due date of an open request by a further period and records the
reason in the request's extensions; the application tells the subject. The module does not cap
the number of extensions.
A pending, partial or failed request is open: getOverdueRequests() lists the open ones past
their due date, and passing the id to export(), erase() or rectify() answers it again. An
unknown, completed, or mismatched (another type or subject) id throws. Retention removes only
completed requests; open requests are kept.
Request rows link on the subject's hash, the HMAC of the user name under the application's
ValidationKey. After a user is renamed, or the ValidationKey changes, getRequests() no longer
finds that subject's earlier requests.
From the command line, in a TShellApplication:
prado-cli privacy/export alice --by=dpo --email=alice@example.com --id=customer:C-1 prado-cli privacy/erase alice # dry run: item counts per provider, erases nothing prado-cli privacy/erase alice --by=dpo --yes # erases prado-cli privacy/erase alice --request=42 --yes # answers the open request 42 prado-cli privacy/extend 42 P2M "many requests this month" prado-cli privacy/requests overdue prado-cli privacy/purge prado-cli privacy/ropa json
export and erase take --by=<name> (who ran it, operator by default), --email=<address>,
--id=<kind>:<value> (repeatable; the subject's other identifiers) and --request=<id>.
privacy/requests lists the open requests with their status.
Retention
TRetentionModule holds a retention policy per kind of data (GDPR Art. 5(1)(e)) and purges what
is past it. TCronModule runs the purge:
<module id="retention" class="belisoful\Privacy\Retention\TRetentionModule" ConsentLogPeriod="P3Y" RequestLogPeriod="P3Y"> <policy Name="orders" Period="P10Y" Description="Invoices: commercial and tax law"> <purger class="belisoful\Privacy\Retention\TDbRetentionPurger" ConnectionID="db" Table="orders" TimeColumn="created_at" /> </policy> <policy Name="uploads" Period="P90D"> <purger class="belisoful\Privacy\Retention\TMethodRetentionPurger" Method="uploads->purgeBefore" /> </policy> </module> <module id="cron" class="Prado\Util\Cron\TCronModule" TaskTimeout="600"> <job Name="privacy-retention" Schedule="17 3 * * *" Task="belisoful\Privacy\Retention\TRetentionCronTask" ModuleId="retention" /> </module>
TRetentionCronTask passes the cron run's cancellation token to the purge, so TaskTimeout (or
the job's Timeout) stops it between policies; a running DELETE is not interrupted.
Task="retention->purge" also works, without a deadline.
A Period is an ISO 8601 duration (P30D, P6M, P3Y, PT12H). The cutoff is now minus the
period. P0D makes the cutoff now: it removes every row of a table keyed by a creation time, and
only the expired rows of one keyed by an expiry time (TDbConsentStore). Purgers implement
IRetentionPurger::purge(int $before): int:
| Purger | Removes |
|---|---|
TDbRetentionPurger |
rows of Table whose TimeColumn is before the cutoff, and that match Condition |
TMethodRetentionPurger |
whatever moduleId->method($before) removes |
| the consent log | every entry of a consent id whose latest entry is before the cutoff, so a history is kept whole until the period has passed since its last change |
| the request log | completed requests answered before the cutoff; open (pending, partial, failed) requests are kept |
TDbConsentStore |
choices that expired before the cutoff |
TDbRetentionPurger properties:
| Property | Default | |
|---|---|---|
Table, TimeColumn |
validated identifiers, quoted with the connection's quoteTableName() and quoteColumnName() |
|
TimeFormat |
unix |
unix (seconds), unix_ms (milliseconds) or datetime (bound as UTC Y-m-d H:i:s, a space separator; the column compares in the database session's time zone, so store UTC) |
Condition |
an extra SQL condition, from the configuration only | |
BatchSize |
0 |
rows per DELETE; 0 deletes in one statement. A batch selects by KeyColumn and repeats until one removes fewer rows |
KeyColumn |
id |
the unique column a batch selects by |
AllowZeroPeriod |
false |
the purger refuses a cutoff at or after now (a P0D policy) unless this is true |
The module adds policies for this package's data (IncludeBuiltIn): consent-log and
privacy-requests (three years by default, the limitation period for claims in many member
states) and consent-state (expired TDbConsentStore rows). A configured policy of the same name
overrides one. purge() runs every policy, logs a failing one, and raises onPurged with the
counts (-1 for a failure); then it throws when a policy failed, so TCronModule logs the task as
failed. purgePolicies() returns the counts without throwing; prado-cli privacy/purge runs it
by hand. The module lists TRetentionCronTask in prado-cli cron/tasks through
fxGetCronTaskInfos.
Records of processing
TProcessingRegistry assembles the records of processing activities (GDPR Art. 30): the
controller's details and every activity the application declares.
<module id="ropa" class="belisoful\Privacy\Retention\TProcessingRegistry" Controller="Example Ltd, 1 Main Street" Contact="privacy@example.com" DpoContact="dpo@example.com" Representative="Example EU BV, 2 Canal Street, Amsterdam"> <activity Name="accounts" Purpose="Running user accounts" LegalBasis="contract" SubjectCategories="customers" DataCategories="name, email address, password hash" RetentionPeriod="until the account is closed" SecurityMeasures="TLS, hashed passwords" /> <activity Name="orders" Purpose="Selling and shipping" LegalBasis="contract" RetentionPolicy="orders" Recipients="Stripe (processor)" Transfers="US: EU-US Data Privacy Framework" /> </module>
Activities come from <activity> elements, from every loaded module that implements
IProcessingActivityProvider (this package's TConsentModule, TPrivacyModule and
TIpRegionResolver declare theirs), and from any component's fxGetProcessingActivities global
event handler. A RetentionPolicy is resolved against TRetentionModule, so the record states
the period the purge enforces. An activity with both a RetentionPeriod and a policy shows both.
A RetentionPolicy that does not resolve (no TRetentionModule, or no policy of that name) shows
the period and WARNING: no purge policy "name". toArray(), toJson() and toMarkdown() give
the record; prado-cli privacy/ropa prints it (json for JSON).
The controller's details are Art. 30(1)(a): Controller, Contact, JointControllers (Art. 26),
Representative (Art. 27, a controller outside the Union) and DpoContact. A processor's records
(Art. 30(2)) are out of scope.
TProcessingActivity |
Art. 30(1) |
|---|---|
Name, Purpose |
(b) purposes |
LegalBasis (consent, contract, legal_obligation, vital_interests, public_task, legitimate_interests), LegalBasisNote |
Art. 6(1) |
SubjectCategories, DataCategories |
(c) |
Recipients |
(d) |
Transfers |
(e) |
RetentionPolicy, RetentionPeriod |
(f) |
SecurityMeasures |
(g) |
Google Analytics (belisoful/ganalytics)
belisoful/ganalytics binds TConsentModule to Google
Consent Mode through its GAnalyticsPrivacyConsentProvider. Neither package requires the other:
the provider finds the consent module by its surface (getConsent(), setConsent(),
onConsentChanged).
<module id="belisoful/prado-privacy" Version="2026-09" /> <module id="privacy-analytics" class="belisoful\GAnalytics\GAnalyticsPrivacyConsentProvider" /> <module id="belisoful/ganalytics" MeasurementId="G-XXXXXXXXXX" ConsentProvider="privacy-analytics" ConsentDefaults='{"analytics_storage": "denied", "ad_storage": "denied", "ad_user_data": "denied", "ad_personalization": "denied"}' />
| Consent category | Google consent types |
|---|---|
analytics |
analytics_storage |
marketing |
ad_storage, ad_user_data, ad_personalization |
functional |
functionality_storage |
personalization |
personalization_storage |
| (always) | security_storage = granted |
- Each page's
gtag('consent', 'default', …)carries the visitor's decided categories. An undecided category keepsConsentDefaults. - Every change from the banner, the preferences or a withdrawal sends
gtag('consent', 'update', …)on the page or callback that made it. - GPC applies before the mapping, so a GPC opt-out denies the three ad types.
Advanced consent mode is the documented default. The Google tag loads with the undecided types denied and sends cookieless pings (IP address, user agent, page) until the visitor decides. Some EU regulators read those pings as processing that needs consent.
Basic consent mode loads no tag and makes no Google request until the visitor grants
analytics or marketing. Set ConsentMode="basic" on the ganalytics module:
<module id="belisoful/ganalytics" MeasurementId="G-XXXXXXXXXX" ConsentProvider="privacy-analytics" ConsentMode="basic" />
- Postback: a grant from the banner or the preferences renders the page with the tag.
- Callback: a grant loads the tag into the open page through a small loader function, with no
eval, so a nonce CSP is enough. - Before consent: events queued before the visitor consents are dropped.
Erasure and records of processing
ganalytics' GAnalyticsPersonalDataProvider is an IPersonalDataProvider and an
IProcessingActivityProvider. TPrivacyModule and TProcessingRegistry discover it as a loaded
module:
<module id="privacy-google" class="belisoful\GAnalytics\GAnalyticsPersonalDataProvider" />
| Request | Effect |
|---|---|
erase() |
One GA4 user deletion (Admin API submitUserDeletion) per identifier of the subject; Google deletes the events collected before the request |
export() |
The property and the identifiers Google Analytics holds the subject's events under |
rectify() |
Nothing: Google Analytics data can only be deleted |
The identifiers are the user_id ganalytics derives from the user name (with UserIdFromUser),
the TDataSubject identifiers ga_user_id, ga_client_id and ga_app_instance_id, the
requester's _ga cookie, and, with EraseUserProvidedData, the subject's email. A user's client
ids from other devices are found only when the application records them and passes them as
ga_client_id. The ganalytics credentials need the analytics.edit scope and the Editor role on
the property.
The registry gains a "Google Analytics" activity: consent as the legal basis, Google as the
recipient, the transfer to the United States, and the property's data retention setting. The
provider's Activity property overrides any field.
Why the adapters live in ganalytics. The framework is vendor-neutral and meant for
pradosoft/prado, which cannot ship a Google binding. The bindings also depend on the framework,
not the other way round. belisoful/prado-privacy is a suggest and a development dependency of
ganalytics, and nothing here refers to ganalytics.
Development
composer install composer fulltest # php -l, php-cs-fixer, PHPStan (level 3), PHPUnit composer coverage-branches # every unexecuted branch under src/ (Xdebug) composer integration # install into a throwaway consumer, check extra.prado # (PRADO from $PRADO_DIR or ../prado.master) npm ci && npx playwright install chromium npx playwright test --project=chromium # the controls in a real browser
CI (.github/workflows/prado-privacy.yml) runs lint, php-cs-fixer, PHPStan and PHPUnit on PHP
8.2 through 8.5 against PRADO's master branch, measures coverage (every line and branch of
src/), runs the Playwright specs in Chromium, Firefox and WebKit, and verifies the Composer
install. A weekly scheduled run catches changes on PRADO master. See AGENTS.md for
the conventions.
License
BSD 3-Clause. See LICENSE.