Search by

belisoful / prado-privacy

belisoful

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

Statistics

Installs: 0

Dependents: 1

Suggesters: 1

Stars: 0

Open Issues: 5

dev-main 2026-11-04 07:23 UTC

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 keeps ConsentDefaults.
  • 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.