From cf866f3181b5d833bf227d919476c18e2ad2409d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 7 Sep 2026 14:44:47 +0200 Subject: [PATCH] Add CookieResolver that delegates cookie handling to Meta's parameter builder Instead of this SDK (and its consumers) hand-parsing the _fbc/_fbp cookies out of a request, the new Setono\MetaConversionsApi\Cookie namespace hands the raw request to facebook/capi-param-builder-php, Meta's own library for exactly this job. It validates existing cookie values and upgrades them to the current format, builds a new fbc from the fbclid query parameter, generates an fbp when the request has none, and reports which cookies to set on the response. The resolver converts the results into the typed Fbc/Fbp value objects. Fbc::fromString()/Fbp::fromString() remain the strict typed layer: the parameter builder has no public single-value parser and its internal validation is structural only (it passes 'a.b.c.d' through), so it cannot replace them. facebook/capi-param-builder-php becomes a direct dependency (it only ships transitively with facebook/php-business-sdk 26.x, not 25.x). FacebookAds\CookieSettings does not comply with the package's PSR-4 mapping (it is loaded via require_once), so PHPStan scans the file explicitly and the dependency analyser ignores the class. --- README.md | 29 ++++++ composer-dependency-analyser.php | 2 + composer.json | 1 + phpstan.dist.neon | 4 + src/Cookie/Cookie.php | 33 +++++++ src/Cookie/CookieResolver.php | 96 ++++++++++++++++++ src/Cookie/CookieResolverInterface.php | 27 +++++ src/Cookie/ResolvedCookies.php | 38 +++++++ tests/Cookie/CookieResolverTest.php | 132 +++++++++++++++++++++++++ 9 files changed, 362 insertions(+) create mode 100644 src/Cookie/Cookie.php create mode 100644 src/Cookie/CookieResolver.php create mode 100644 src/Cookie/CookieResolverInterface.php create mode 100644 src/Cookie/ResolvedCookies.php create mode 100644 tests/Cookie/CookieResolverTest.php diff --git a/README.md b/README.md index 88bb458..58bec77 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,35 @@ $event->userData->fbc = Fbc::fromString($_COOKIE['_fbc']); $event->userData->fbp = Fbp::fromString($_COOKIE['_fbp']); ``` +### Resolving the cookies with Meta's parameter builder + +Instead of reading and parsing the cookies yourself, you can hand the raw request to the `CookieResolver`, which +delegates to Meta's own [parameter builder](https://github.com/facebook/capi-param-builder-php) +(`facebook/capi-param-builder-php`). It validates existing cookie values and upgrades them to the format Meta writes +today, builds a new `fbc` from the `fbclid` query parameter, generates an `fbp` when the request has none, and tells +you which cookies to set on the response: + +```php +use Setono\MetaConversionsApi\Cookie\CookieResolver; + +$resolver = new CookieResolver(); +$resolvedCookies = $resolver->resolve($_SERVER['HTTP_HOST'], $_GET, $_COOKIE); + +$event->userData->fbc = $resolvedCookies->fbc; +$event->userData->fbp = $resolvedCookies->fbp; + +foreach ($resolvedCookies->cookiesToSet as $cookie) { + setcookie($cookie->name, $cookie->value, [ + 'expires' => time() + $cookie->maxAge, + 'path' => '/', + 'domain' => $cookie->domain ?? '', + ]); +} +``` + +On a multi-domain setup, pass your domains so the cookie domain is derived correctly, e.g. +`new CookieResolver(['example.co.uk'])`. + ## Using your own HTTP client By default the client auto-discovers a PSR-18 client and PSR-17 factories. To inject your own (e.g. a preconfigured diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 73f107c..6f78df4 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -9,4 +9,6 @@ ->addPathToExclude(__DIR__ . '/tests') ->ignoreErrorsOnPackage('psr/http-client-implementation', [ErrorType::UNUSED_DEPENDENCY]) ->ignoreErrorsOnPackage('psr/http-factory-implementation', [ErrorType::UNUSED_DEPENDENCY]) + // loaded by \FacebookAds\ParamBuilder via require_once; it does not comply with the package's PSR-4 mapping, so it cannot be autoloaded + ->ignoreUnknownClasses(['FacebookAds\CookieSettings']) ; diff --git a/composer.json b/composer.json index e10bcf7..1ea5934 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": ">=8.1", "ext-json": "*", + "facebook/capi-param-builder-php": "^1.3.1", "facebook/php-business-sdk": "^25.0 || ^26.0", "php-http/discovery": "^1.20", "psr/http-client": "^1.0", diff --git a/phpstan.dist.neon b/phpstan.dist.neon index ca7eec6..8235631 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,9 @@ parameters: level: max + # CookieSettings is loaded by ParamBuilder via require_once and does not comply with the + # package's PSR-4 mapping, so PHPStan cannot autoload it + scanFiles: + - vendor/facebook/capi-param-builder-php/php/capi-param-builder/src/model/CookieSettings.php treatPhpDocTypesAsCertain: false paths: - src diff --git a/src/Cookie/Cookie.php b/src/Cookie/Cookie.php new file mode 100644 index 0000000..c12758e --- /dev/null +++ b/src/Cookie/Cookie.php @@ -0,0 +1,33 @@ +name = $name; + $this->value = $value; + $this->maxAge = $maxAge; + $this->domain = $domain; + } +} diff --git a/src/Cookie/CookieResolver.php b/src/Cookie/CookieResolver.php new file mode 100644 index 0000000..fa7d9aa --- /dev/null +++ b/src/Cookie/CookieResolver.php @@ -0,0 +1,96 @@ +|ETLDPlus1Resolver|null */ + private array|ETLDPlus1Resolver|null $domains; + + /** + * @param list|ETLDPlus1Resolver|null $domains a list of your domains, used to derive the cookie domain + * (e.g. ['example.co.uk']), or your own eTLD+1 resolver. + * If null, the registrable domain is guessed from the host + */ + public function __construct(array|ETLDPlus1Resolver|null $domains = null) + { + $this->domains = $domains; + } + + public function resolve( + string $host, + array $query, + array $cookies, + ?string $referer = null, + ?string $xForwardedFor = null, + ?string $remoteAddress = null, + ): ResolvedCookies { + $paramBuilder = new ParamBuilder($this->domains); + $paramBuilder->processRequest($host, $query, $cookies, $referer, $xForwardedFor, $remoteAddress); + + $fbc = $paramBuilder->getFbc(); + Assert::nullOrString($fbc); + + $fbp = $paramBuilder->getFbp(); + Assert::nullOrString($fbp); + + $cookieSettings = $paramBuilder->getCookiesToSet(); + Assert::isArray($cookieSettings); + + $cookiesToSet = []; + foreach ($cookieSettings as $cookieSetting) { + Assert::isInstanceOf($cookieSetting, CookieSettings::class); + Assert::string($cookieSetting->name); + Assert::string($cookieSetting->value); + Assert::integer($cookieSetting->max_age); + Assert::nullOrString($cookieSetting->domain); + + $cookiesToSet[] = new Cookie($cookieSetting->name, $cookieSetting->value, $cookieSetting->max_age, $cookieSetting->domain); + } + + return new ResolvedCookies( + null === $fbc ? null : self::parseFbc($fbc), + null === $fbp ? null : self::parseFbp($fbp), + $cookiesToSet, + ); + } + + /** + * Meta's parameter builder only validates the segment count and the appendix of an existing cookie, + * so a malformed cookie can be passed through. Such a value cannot be represented as a value object + * and is returned as null + */ + private static function parseFbc(string $value): ?Fbc + { + try { + return Fbc::fromString($value); + } catch (\InvalidArgumentException) { + return null; + } + } + + private static function parseFbp(string $value): ?Fbp + { + try { + return Fbp::fromString($value); + } catch (\InvalidArgumentException) { + return null; + } + } +} diff --git a/src/Cookie/CookieResolverInterface.php b/src/Cookie/CookieResolverInterface.php new file mode 100644 index 0000000..7471331 --- /dev/null +++ b/src/Cookie/CookieResolverInterface.php @@ -0,0 +1,27 @@ + $query the query parameters of the current request, e.g. $_GET + * @param array $cookies the cookies of the current request, e.g. $_COOKIE + * @param string|null $referer the Referer header of the current request, if any + * @param string|null $xForwardedFor the X-Forwarded-For header of the current request, if any + * @param string|null $remoteAddress the remote address of the current request, if any + */ + public function resolve( + string $host, + array $query, + array $cookies, + ?string $referer = null, + ?string $xForwardedFor = null, + ?string $remoteAddress = null, + ): ResolvedCookies; +} diff --git a/src/Cookie/ResolvedCookies.php b/src/Cookie/ResolvedCookies.php new file mode 100644 index 0000000..6e9846b --- /dev/null +++ b/src/Cookie/ResolvedCookies.php @@ -0,0 +1,38 @@ + + */ + public readonly array $cookiesToSet; + + /** + * @param list $cookiesToSet + */ + public function __construct(?Fbc $fbc, ?Fbp $fbp, array $cookiesToSet) + { + $this->fbc = $fbc; + $this->fbp = $fbp; + $this->cookiesToSet = $cookiesToSet; + } +} diff --git a/tests/Cookie/CookieResolverTest.php b/tests/Cookie/CookieResolverTest.php new file mode 100644 index 0000000..2348f13 --- /dev/null +++ b/tests/Cookie/CookieResolverTest.php @@ -0,0 +1,132 @@ +resolve('www.example.com', [], ['_fbc' => $fbc, '_fbp' => $fbp]); + + self::assertNotNull($resolvedCookies->fbc); + self::assertSame($fbc, $resolvedCookies->fbc->value()); + self::assertNotNull($resolvedCookies->fbp); + self::assertSame($fbp, $resolvedCookies->fbp->value()); + self::assertSame([], $resolvedCookies->cookiesToSet); + } + + /** + * @test + */ + public function it_upgrades_a_four_segment_cookie_with_an_appendix_and_sets_it(): void + { + $fbp = 'fb.1.1656874832584.1088522659'; + + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbp' => $fbp]); + + self::assertNotNull($resolvedCookies->fbp); + self::assertMatchesRegularExpression('/^fb\.1\.1656874832584\.1088522659\.[A-Za-z0-9_-]{8}$/', $resolvedCookies->fbp->value()); + self::assertNotNull($resolvedCookies->fbp->getAppendix()); + + $cookie = self::cookie($resolvedCookies, '_fbp'); + self::assertSame($resolvedCookies->fbp->value(), $cookie->value); + self::assertSame(90 * 24 * 3600, $cookie->maxAge); + self::assertSame('example.com', $cookie->domain); + } + + /** + * @test + */ + public function it_builds_an_fbc_from_the_fbclid_query_parameter(): void + { + $before = (int) floor(microtime(true) * 1000); + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', ['fbclid' => 'IwAR1a-b_c'], []); + $after = (int) ceil(microtime(true) * 1000); + + self::assertNotNull($resolvedCookies->fbc); + self::assertSame('IwAR1a-b_c', $resolvedCookies->fbc->getClickId()); + self::assertSame(1, $resolvedCookies->fbc->getSubdomainIndex()); + self::assertGreaterThanOrEqual($before, $resolvedCookies->fbc->getCreationTime()); + self::assertLessThanOrEqual($after, $resolvedCookies->fbc->getCreationTime()); + self::assertNotNull($resolvedCookies->fbc->getAppendix()); + + self::assertSame($resolvedCookies->fbc->value(), self::cookie($resolvedCookies, '_fbc')->value); + } + + /** + * @test + */ + public function it_generates_an_fbp_when_the_request_has_none(): void + { + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], []); + + self::assertNull($resolvedCookies->fbc); + self::assertNotNull($resolvedCookies->fbp); + self::assertSame(1, $resolvedCookies->fbp->getSubdomainIndex()); + self::assertNotNull($resolvedCookies->fbp->getAppendix()); + + self::assertSame($resolvedCookies->fbp->value(), self::cookie($resolvedCookies, '_fbp')->value); + } + + /** + * @test + */ + public function it_regenerates_the_fbp_when_the_existing_cookie_has_an_invalid_appendix(): void + { + // a two character appendix must be one of the language tokens Meta supports, so ZZ is invalid + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbp' => 'fb.1.1656874832584.1088522659.ZZ']); + + self::assertNotNull($resolvedCookies->fbp); + self::assertGreaterThan(1656874832584, $resolvedCookies->fbp->getCreationTime()); + + self::assertSame($resolvedCookies->fbp->value(), self::cookie($resolvedCookies, '_fbp')->value); + } + + /** + * @test + */ + public function it_returns_null_for_a_cookie_that_cannot_be_represented_as_a_value_object(): void + { + // Meta's parameter builder only validates the segment count, so these pass through it, + // but they are not valid fbc/fbp values + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbc' => 'a.b.c.d', '_fbp' => 'e.f.g.h']); + + self::assertNull($resolvedCookies->fbc); + self::assertNull($resolvedCookies->fbp); + self::assertStringStartsWith('a.b.c.d.', self::cookie($resolvedCookies, '_fbc')->value); + self::assertStringStartsWith('e.f.g.h.', self::cookie($resolvedCookies, '_fbp')->value); + } + + /** + * @test + */ + public function it_uses_the_given_domains_to_derive_the_cookie_domain_and_subdomain_index(): void + { + $resolvedCookies = (new CookieResolver(['example.co.uk']))->resolve('shop.example.co.uk', [], []); + + self::assertNotNull($resolvedCookies->fbp); + self::assertSame(2, $resolvedCookies->fbp->getSubdomainIndex()); + self::assertSame('example.co.uk', self::cookie($resolvedCookies, '_fbp')->domain); + } + + private static function cookie(ResolvedCookies $resolvedCookies, string $name): Cookie + { + foreach ($resolvedCookies->cookiesToSet as $cookie) { + if ($cookie->name === $name) { + return $cookie; + } + } + + self::fail(sprintf('No cookie named "%s" was set', $name)); + } +}