Search by

flenczewski / php-iab-tcf

flen

Encode and decode IAB TCF v2.3 (GDPR Transparency & Consent Framework) TC Strings in PHP, with Global Vendor List tooling and a CLI decoder.

Package info

github.com/flenczewski/php-iab-tcf

pkg:composer/flenczewski/php-iab-tcf

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v2.2.0 2026-09-27 21:59 UTC

This package is auto-updated.

Last update: 2026-10-05 09:59:14 UTC


README

CI Packagist License

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. TcModel has no fields for publisher-specific purposes/custom purposes. Contributions welcome.
  • Enforcing MaxVendorId can reject strings 2.0.0 decoded. A vendor section whose range entries name ids above its own declared MaxVendorId is 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 raises InvalidTcStringException. 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/Gvl model only the fields relevant to consent validation (ids, names, purpose/feature associations) — not the full GVL schema (illustrations, dataDeclaration, dataRetention, standardTexts, etc). Use GvlFetcher::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.