flenczewski / php-iab-tcf
Encode and decode IAB TCF v2.3 (GDPR Transparency & Consent Framework) TC Strings in PHP, with Global Vendor List tooling and a CLI decoder.
Requires
- php: ^8.1
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
Suggests
- psr/http-client: To fetch the Global Vendor List through your own PSR-18 client instead of the bundled stream client
Provides
None
Conflicts
None
Replaces
None
README
Encode and decode IAB TCF v2 (GDPR Transparency & Consent Framework) TC Strings in PHP, including v2.3's mandatory Disclosed Vendors segment, plus tools for the Global Vendor List (GVL) and a CLI decoder. No runtime dependencies beyond PHP and ext-json.
Install
composer require flenczewski/php-iab-tcf
Requires PHP 8.1+ and ext-json. No other runtime dependencies.
Upgrading from 1.x? See UPGRADE-2.0.md. 2.0 fixes a memory-exhaustion DoS that affects every 1.x release — see SECURITY.md.
Usage
Encode
use Flenczewski\IabTcf\TcModel; use Flenczewski\IabTcf\TcStringEncoder; use Flenczewski\IabTcf\PublisherRestriction; use Flenczewski\IabTcf\RestrictionType; $model = new TcModel( cmpId: 300, cmpVersion: 1, consentScreen: 1, consentLanguage: 'EN', vendorListVersion: 175, tcfPolicyVersion: 5, isServiceSpecific: true, purposesConsent: [1, 2, 3, 4], purposesLITransparency: [2, 7], vendorConsents: [1, 2, 3, 500], vendorLegitimateInterests: [3, 4], publisherRestrictions: [ new PublisherRestriction(2, RestrictionType::REQUIRE_CONSENT, [1, 2, 3]), ], disclosedVendors: [1, 2, 3, 500], ); $tcString = TcStringEncoder::encode($model); // "CPX...AA.IA..." — dot-separated, base64url-encoded segments
A model without created/lastUpdated is stamped with the current time, so
encoding it twice gives two different strings. Pass the time yourself when you
need reproducible output:
$tcString = TcStringEncoder::encode($model, new DateTimeImmutable('2026-01-01T00:00:00Z'));
The constructor enforces TCF field bounds only. Before encoding a model of
your own, ask it what the TCF policy forbids but the wire format could still
carry — legitimate interest for purpose 1 or 3–6 (only purpose 1 when
tcfPolicyVersion predates TCF v2.2, i.e. is below 4), the reserved UNDEFINED
restriction type, a vendor given two restriction types for one purpose,
created after lastUpdated:
$model->policyViolations(); // string[] — empty when there is nothing to report
Decode
use Flenczewski\IabTcf\TcStringDecoder; $model = TcStringDecoder::decode($tcString); $model->cmpId; // 300 $model->purposesConsent; // [1, 2, 3, 4] $model->vendorConsents; // [1, 2, 3, 500] $model->publisherRestrictions; // PublisherRestriction[]
decode() refuses input longer than Spec::MAX_TC_STRING_LENGTH (256 KiB),
which is above anything this package can encode. Real TC Strings are a few
hundred bytes to a few kilobytes, so when the string comes from a cookie or a
query parameter, pass a tighter limit:
$model = TcStringDecoder::decode($_COOKIE['euconsent-v2'] ?? '', maxLength: 8192);
TCF v2.3
TCF v2.3 made the Disclosed Vendors segment (segment type 1) mandatory — it was optional in v2.0-v2.2 — to remove ambiguity around Legitimate Interest signalling. See the IAB Tech Lab consent string specification for the normative text and the compliance dates that apply to your deployment; this README deliberately does not restate them, because they change and a stale date here is worse than none.
This library follows suit: TcModel::$disclosedVendors defaults to [] (an empty, but present, disclosed-vendor set), so TcStringEncoder::encode() always emits the segment by default. If you deliberately need pre-v2.3 wire compatibility (omitting the segment entirely), pass disclosedVendors: null explicitly.
Decoding stays backward compatible, and distinguishes the two cases:
$model->disclosedVendors |
Meaning |
|---|---|
null |
The decoded string carried no Disclosed Vendors segment (pre-v2.3) |
[] |
The segment was present and disclosed no vendors |
Keeping them distinct is what stops a decode/encode cycle from silently appending an empty segment to a pre-v2.3 string and changing its meaning from "unknown" to "zero vendors disclosed".
The bit layout of the Core segment itself is unchanged between v2.0 and v2.3 — only the mandatoriness of this one segment changed. This library does not hardcode a single "correct" tcfPolicyVersion for you: that value should be sourced from the Global Vendor List you're operating against (Gvl::$tcfPolicyVersion, see below), since it can change between GVL releases.
Round-tripping
TcStringEncoder::encode(TcStringDecoder::decode($s)) === $s holds for any
canonically encoded TC String whose segments this package models — which is
what this package's own encoder produces.
It does not hold in general, because encoding is canonical: the decoder accepts several inputs that mean the same thing and the encoder emits only one of them. Concretely, a re-encoded string differs from its input when the input:
| Input property | What re-encoding does |
|---|---|
| Carries a Publisher TC segment (type 3) | Drops it — the segment is not modelled (see "Known limitations") |
| Orders segments Allowed Vendors before Disclosed Vendors | Emits Disclosed Vendors first |
| Range-encodes a vendor section where a bitfield would be shorter (or vice versa) | Picks whichever encoding is smaller |
| Has overlapping, unsorted or duplicated range entries | Normalises them to a sorted, de-duplicated set |
| Pads the Core segment with more trailing bits than base64 alignment requires | Emits only the alignment padding |
All five are lossless as far as the decoded model is concerned — the same
TcModel comes back either way.
The model side does hold in general: TcModel stores id lists sorted and
de-duplicated and the two-letter codes upper-cased — the only forms the wire
format can carry — so a model's fields equal those of
TcStringDecoder::decode(TcStringEncoder::encode($model)). Do not use a re-encoded string to test
whether two cookies are equal, or as a cache key derived from a third-party
string; compare the decoded TcModel fields instead.
Trailing bits beyond the Core segment's fields are ignored rather than rejected, since base64 padding already makes the segment length inexact.
Global Vendor List (GVL)
Parse, query, and cross-check consents against the Global Vendor List.
Bundled vs. live fetch
Gvl::bundled() |
GvlFetcher::fetchLatest() |
|
|---|---|---|
| Network required at runtime | No | Yes |
| Cost | Parses a ~2 MB file once per process (see Caching) | One HTTP request, plus the same parse |
| Freshness | As of the release you installed (see below) | Always current |
The bundled file is refreshed weekly on this repository's main branch, but
Composer installs tagged releases — so what you get is the list as it stood
when your installed version was released, which can be weeks or months old.
Use Gvl::bundled() when that is acceptable. Reach for GvlFetcher if you
need the current list and can tolerate a network dependency (or want a specific
archived version).
use Flenczewski\IabTcf\Gvl\Gvl; use Flenczewski\IabTcf\Gvl\GvlFetcher; // Fast, offline, uses the copy bundled with this package (resources/vendor-list.json). $gvl = Gvl::bundled(); // Or parse your own JSON payload (e.g. one you cached yourself): $gvl = Gvl::fromJson(file_get_contents('/path/to/vendor-list.json')); // Or fetch over the network: $gvl = (new GvlFetcher())->fetchLatest(); $gvl = (new GvlFetcher())->fetchVersion(138); // a specific archived version
The default transport is StreamHttpClient: it bounds each read
(timeoutSeconds, default 10) and the body download (totalTimeoutSeconds,
default 60), verifies TLS, does not follow redirects, and checks the HTTP status
rather than handing you a 404 page as if it were a vendor list. It fetches any
URL PHP's stream wrappers accept, file:// included, so never pass it a URL
taken from untrusted input. If your project already has a PSR-18 client, pass
it in instead — psr/http-client is suggested, never required:
use Flenczewski\IabTcf\Http\Psr18HttpClient; $fetcher = new GvlFetcher(new Psr18HttpClient($psr18Client, $psr17RequestFactory));
totalTimeoutSeconds is enforced once the response headers have arrived:
PHP's HTTP wrapper connects and reads them inside fopen(), where only the
per-read timeout applies, so a server trickling its headers can outlast it.
Where a hard overall limit matters, use a PSR-18 client configured with one.
Both transports refuse a body larger than maxResponseBytes (64 MB by default).
Timeouts of a PSR-18 client are that client's to configure, and so is the
transfer: a client that buffers the whole response (Guzzle's default) has
downloaded it before the cap is checked — have it stream the body
(Guzzle: 'stream' => true) for the cap to stop the download itself.
Querying
$gvl->vendorListVersion; // int $gvl->tcfPolicyVersion; // int — source of truth for TcModel::$tcfPolicyVersion $gvl->vendors[755]->name; // string, or check with isset() $gvl->getVendorsWithConsentPurpose(1); // Vendor[] — vendors that process purpose 1 under consent $gvl->getVendorsWithLegIntPurpose(2); // Vendor[] $gvl->getVendorsWithFeature(1); // Vendor[] $gvl->getVendorsWithSpecialFeature(1); // Vendor[] $gvl->getVendorsWithSpecialPurpose(1); // Vendor[] $narrowed = $gvl->narrowVendorsTo([1, 2, 755]); // new Gvl containing only these vendor ids
The list keeps an entry for every vendor that was ever registered; a deleted
vendor carries a deletedDate so that older TC Strings naming it still
resolve. $gvl->vendors holds them all, but the getVendorsWith*() queries
leave out vendors deleted as of the list's lastUpdated — pass
includeDeleted: true to get them back:
$gvl->vendors[8]->deletedDate; // ?DateTimeImmutable $gvl->isDeleted($gvl->vendors[8]); // bool, judged as of $gvl->lastUpdated $gvl->getVendorsWithConsentPurpose(1, includeDeleted: true);
Cross-checking a TcModel
$problems = $gvl->validateConsents($model); // e.g. ["Vendor 65535 has consent in the TcModel but does not exist in this GVL."]
This is a lightweight sanity check (unknown or deleted vendor ids, vendors with consent/LI but no matching declared purpose in the GVL) — not a formal, exhaustive TCF validator.
Caching
Parsing the full list takes tens of milliseconds, most of it json_decode().
Gvl::bundled() remembers the result for the rest of the process, which under
PHP-FPM means once per request. A parsed Gvl survives serialize(), so cache
it where your workers share memory, keyed by the package version (or by
vendorListVersion for a fetched list):
$key = 'iab-tcf-gvl-' . \Composer\InstalledVersions::getVersion('flenczewski/php-iab-tcf'); $gvl = apcu_entry($key, static fn () => Gvl::bundled()); // or any PSR-16 cache
Keeping the bundled GVL fresh
resources/vendor-list.json is the full, real Global Vendor List, refreshed automatically once a week on main by .github/workflows/update-gvl.yml, which runs composer update-gvl (== bin/iab-tcf update-gvl), runs the test suite, and commits the result if it changed. The refresh reaches Composer users with the next tagged release. In a checkout of this package you can run the same refresh yourself:
composer update-gvl
# or: bin/iab-tcf update-gvl [path/to/output.json]
CLI
php bin/iab-tcf decode "CPX...AA.IA..."
Prints the decoded model as pretty-printed JSON to stdout; exits non-zero with a message on stderr for a missing/invalid argument. When this package is installed as a dependency, the command is available at vendor/bin/iab-tcf (Composer does not link a package's own bin entry into vendor/bin for its own repo, so inside this repo's checkout, invoke it as php bin/iab-tcf instead).
php bin/iab-tcf update-gvl [path] # fetch the latest GVL and write it to `path` php bin/iab-tcf --help # usage, printed to stdout, exit 0
update-gvl replaces the file atomically. The path defaults to
resources/vendor-list.json only in a checkout of this package; installed as a
dependency (vendor/bin/iab-tcf), it is required, because the default would
point inside vendor/.
Error handling
Every exception this package throws implements
Flenczewski\IabTcf\Exception\IabTcfException, so one catch covers all of
them. Concrete classes still extend their closest SPL ancestor, so existing
catch (\InvalidArgumentException $e) code keeps working.
| Exception | Thrown when |
|---|---|
InvalidTcStringException |
A TC String cannot be decoded — the only type TcStringDecoder::decode() throws for any input string (a maxLength below 1 is a programming error and throws InvalidArgumentException) |
InvalidArgumentException |
A value handed to the encoder or a model is outside its TCF field bounds |
OutOfRangeException |
A read ran past the end of a bit buffer |
GvlException |
The Global Vendor List could not be fetched or parsed |
IabTcfException |
Marker interface implemented by all of the above |
use Flenczewski\IabTcf\Exception\InvalidTcStringException; try { $model = TcStringDecoder::decode($_COOKIE['euconsent-v2'] ?? ''); } catch (InvalidTcStringException $e) { // Malformed, truncated, non-base64, wrong version, hostile — all arrive here. // The underlying cause is preserved: $e->getPrevious() $model = null; }
Security
TC Strings normally arrive from cookies and query parameters, so treat them as
untrusted input. TcStringDecoder::decode() validates structure and bounds
what it will allocate: the input length is capped before anything is decoded
(256 KiB by default — pass a tighter maxLength for cookie input, see
Decode), and a range list is checked against the 16-bit vendor id
space before it is expanded, so a small hostile string cannot inflate into a
huge array.
A vendor section's declared MaxVendorId is enforced against its range
entries, so a section cannot name ids outside the space it claims. Both HTTP
transports bound the response body they will buffer (64 MB by default,
configurable via maxResponseBytes), and StreamHttpClient bounds the whole
request in time.
Version 1.x did not bound range expansion at all and is vulnerable to memory exhaustion. See SECURITY.md for the supported versions and how to report a vulnerability.
Versioning
This project follows Semantic Versioning. The public API
is every class under Flenczewski\IabTcf\ that is not marked @internal.
Breaking changes are listed in CHANGELOG.md, with migration
steps in UPGRADE-2.0.md.
What's implemented
- Core String (segment 0): all fields per the TCF v2 Core Segment spec — version, timestamps, CMP metadata, consent language, special features, purposes consent/LI, publisher country code, vendor consents/LI (BitField or Range encoding, whichever is smaller), and Publisher Restrictions.
- Disclosed Vendors segment (segment type 1) — mandatory by default per TCF v2.3, see above.
- Allowed Vendors segment (segment type 2).
- Global Vendor List parsing, querying, and TcModel cross-checking (
Flenczewski\IabTcf\Gvl\*). - CLI decoder and GVL-refresh command (
bin/iab-tcf).
Known limitations
- Publisher TC segment (segment type 3) is not implemented. If present in a decoded TC String, it is skipped, and re-encoding the model drops it — see Round-tripping.
TcModelhas no fields for publisher-specific purposes/custom purposes. Contributions welcome. - Enforcing
MaxVendorIdcan reject strings 2.0.0 decoded. A vendor section whose range entries name ids above its own declaredMaxVendorIdis non-conformant, but the reference implementation (iabtcf-es) does not reject it — so a third-party cookie produced through it decoded in 2.0.0 and now raisesInvalidTcStringException. Worth exercising against a sample of real traffic when upgrading. - Only Core String version 2 is supported (the only version defined by TCF v2.x). Decoding a v1 or other-version string throws
InvalidTcStringException. Vendor/Gvlmodel only the fields relevant to consent validation (ids, names, purpose/feature associations) — not the full GVL schema (illustrations,dataDeclaration,dataRetention,standardTexts, etc). UseGvlFetcher::fetchLatestRaw()/fetchVersionRaw()if you need the untouched raw JSON.
Testing
composer test
PHPUnit needs the dom, mbstring, xmlwriter and tokenizer extensions. If
your PHP lacks them, composer test transparently re-runs the suite in the
official composer:2 Docker image instead, so the command works either way.
Arguments are forwarded:
composer test -- --filter ReferenceVectorTest composer analyse # PHPStan, level max composer cs # coding standards (composer cs-fix applies them)
See CONTRIBUTING.md for the full workflow.
License
MIT — see LICENSE.