Skip to content

Commit cf866f3

Browse files
committed
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.
1 parent 36270b7 commit cf866f3

9 files changed

Lines changed: 362 additions & 0 deletions

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,35 @@ $event->userData->fbc = Fbc::fromString($_COOKIE['_fbc']);
183183
$event->userData->fbp = Fbp::fromString($_COOKIE['_fbp']);
184184
```
185185

186+
### Resolving the cookies with Meta's parameter builder
187+
188+
Instead of reading and parsing the cookies yourself, you can hand the raw request to the `CookieResolver`, which
189+
delegates to Meta's own [parameter builder](https://github.com/facebook/capi-param-builder-php)
190+
(`facebook/capi-param-builder-php`). It validates existing cookie values and upgrades them to the format Meta writes
191+
today, builds a new `fbc` from the `fbclid` query parameter, generates an `fbp` when the request has none, and tells
192+
you which cookies to set on the response:
193+
194+
```php
195+
use Setono\MetaConversionsApi\Cookie\CookieResolver;
196+
197+
$resolver = new CookieResolver();
198+
$resolvedCookies = $resolver->resolve($_SERVER['HTTP_HOST'], $_GET, $_COOKIE);
199+
200+
$event->userData->fbc = $resolvedCookies->fbc;
201+
$event->userData->fbp = $resolvedCookies->fbp;
202+
203+
foreach ($resolvedCookies->cookiesToSet as $cookie) {
204+
setcookie($cookie->name, $cookie->value, [
205+
'expires' => time() + $cookie->maxAge,
206+
'path' => '/',
207+
'domain' => $cookie->domain ?? '',
208+
]);
209+
}
210+
```
211+
212+
On a multi-domain setup, pass your domains so the cookie domain is derived correctly, e.g.
213+
`new CookieResolver(['example.co.uk'])`.
214+
186215
## Using your own HTTP client
187216

188217
By default the client auto-discovers a PSR-18 client and PSR-17 factories. To inject your own (e.g. a preconfigured

composer-dependency-analyser.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@
99
->addPathToExclude(__DIR__ . '/tests')
1010
->ignoreErrorsOnPackage('psr/http-client-implementation', [ErrorType::UNUSED_DEPENDENCY])
1111
->ignoreErrorsOnPackage('psr/http-factory-implementation', [ErrorType::UNUSED_DEPENDENCY])
12+
// loaded by \FacebookAds\ParamBuilder via require_once; it does not comply with the package's PSR-4 mapping, so it cannot be autoloaded
13+
->ignoreUnknownClasses(['FacebookAds\CookieSettings'])
1214
;

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"require": {
1313
"php": ">=8.1",
1414
"ext-json": "*",
15+
"facebook/capi-param-builder-php": "^1.3.1",
1516
"facebook/php-business-sdk": "^25.0 || ^26.0",
1617
"php-http/discovery": "^1.20",
1718
"psr/http-client": "^1.0",

phpstan.dist.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
parameters:
22
level: max
3+
# CookieSettings is loaded by ParamBuilder via require_once and does not comply with the
4+
# package's PSR-4 mapping, so PHPStan cannot autoload it
5+
scanFiles:
6+
- vendor/facebook/capi-param-builder-php/php/capi-param-builder/src/model/CookieSettings.php
37
treatPhpDocTypesAsCertain: false
48
paths:
59
- src

src/Cookie/Cookie.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\MetaConversionsApi\Cookie;
6+
7+
/**
8+
* A cookie that should be set on the response, e.g. with setcookie() or your framework's response API
9+
*/
10+
final class Cookie
11+
{
12+
public readonly string $name;
13+
14+
public readonly string $value;
15+
16+
/**
17+
* The max age in seconds
18+
*/
19+
public readonly int $maxAge;
20+
21+
/**
22+
* The registrable domain the cookie should be set on, e.g. 'example.com'. Null if it could not be derived
23+
*/
24+
public readonly ?string $domain;
25+
26+
public function __construct(string $name, string $value, int $maxAge, ?string $domain)
27+
{
28+
$this->name = $name;
29+
$this->value = $value;
30+
$this->maxAge = $maxAge;
31+
$this->domain = $domain;
32+
}
33+
}

src/Cookie/CookieResolver.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\MetaConversionsApi\Cookie;
6+
7+
use FacebookAds\CookieSettings;
8+
use FacebookAds\ETLDPlus1Resolver;
9+
use FacebookAds\ParamBuilder;
10+
use Setono\MetaConversionsApi\ValueObject\Fbc;
11+
use Setono\MetaConversionsApi\ValueObject\Fbp;
12+
use Webmozart\Assert\Assert;
13+
14+
/**
15+
* Resolves the _fbc/_fbp cookies for a request by delegating to Meta's own parameter builder
16+
* (facebook/capi-param-builder-php), so this SDK does not have to replicate how Meta reads,
17+
* refreshes and writes those cookies: existing values are validated and upgraded to the current
18+
* format, a new fbc is built from the fbclid query parameter, and an fbp is generated when the
19+
* request has none
20+
*/
21+
final class CookieResolver implements CookieResolverInterface
22+
{
23+
/** @var list<string>|ETLDPlus1Resolver|null */
24+
private array|ETLDPlus1Resolver|null $domains;
25+
26+
/**
27+
* @param list<string>|ETLDPlus1Resolver|null $domains a list of your domains, used to derive the cookie domain
28+
* (e.g. ['example.co.uk']), or your own eTLD+1 resolver.
29+
* If null, the registrable domain is guessed from the host
30+
*/
31+
public function __construct(array|ETLDPlus1Resolver|null $domains = null)
32+
{
33+
$this->domains = $domains;
34+
}
35+
36+
public function resolve(
37+
string $host,
38+
array $query,
39+
array $cookies,
40+
?string $referer = null,
41+
?string $xForwardedFor = null,
42+
?string $remoteAddress = null,
43+
): ResolvedCookies {
44+
$paramBuilder = new ParamBuilder($this->domains);
45+
$paramBuilder->processRequest($host, $query, $cookies, $referer, $xForwardedFor, $remoteAddress);
46+
47+
$fbc = $paramBuilder->getFbc();
48+
Assert::nullOrString($fbc);
49+
50+
$fbp = $paramBuilder->getFbp();
51+
Assert::nullOrString($fbp);
52+
53+
$cookieSettings = $paramBuilder->getCookiesToSet();
54+
Assert::isArray($cookieSettings);
55+
56+
$cookiesToSet = [];
57+
foreach ($cookieSettings as $cookieSetting) {
58+
Assert::isInstanceOf($cookieSetting, CookieSettings::class);
59+
Assert::string($cookieSetting->name);
60+
Assert::string($cookieSetting->value);
61+
Assert::integer($cookieSetting->max_age);
62+
Assert::nullOrString($cookieSetting->domain);
63+
64+
$cookiesToSet[] = new Cookie($cookieSetting->name, $cookieSetting->value, $cookieSetting->max_age, $cookieSetting->domain);
65+
}
66+
67+
return new ResolvedCookies(
68+
null === $fbc ? null : self::parseFbc($fbc),
69+
null === $fbp ? null : self::parseFbp($fbp),
70+
$cookiesToSet,
71+
);
72+
}
73+
74+
/**
75+
* Meta's parameter builder only validates the segment count and the appendix of an existing cookie,
76+
* so a malformed cookie can be passed through. Such a value cannot be represented as a value object
77+
* and is returned as null
78+
*/
79+
private static function parseFbc(string $value): ?Fbc
80+
{
81+
try {
82+
return Fbc::fromString($value);
83+
} catch (\InvalidArgumentException) {
84+
return null;
85+
}
86+
}
87+
88+
private static function parseFbp(string $value): ?Fbp
89+
{
90+
try {
91+
return Fbp::fromString($value);
92+
} catch (\InvalidArgumentException) {
93+
return null;
94+
}
95+
}
96+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\MetaConversionsApi\Cookie;
6+
7+
interface CookieResolverInterface
8+
{
9+
/**
10+
* Takes the raw ingredients of an HTTP request and resolves the Meta cookies for it
11+
*
12+
* @param string $host the HTTP host of the current request, e.g. 'www.example.com'
13+
* @param array<array-key, mixed> $query the query parameters of the current request, e.g. $_GET
14+
* @param array<array-key, mixed> $cookies the cookies of the current request, e.g. $_COOKIE
15+
* @param string|null $referer the Referer header of the current request, if any
16+
* @param string|null $xForwardedFor the X-Forwarded-For header of the current request, if any
17+
* @param string|null $remoteAddress the remote address of the current request, if any
18+
*/
19+
public function resolve(
20+
string $host,
21+
array $query,
22+
array $cookies,
23+
?string $referer = null,
24+
?string $xForwardedFor = null,
25+
?string $remoteAddress = null,
26+
): ResolvedCookies;
27+
}

src/Cookie/ResolvedCookies.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\MetaConversionsApi\Cookie;
6+
7+
use Setono\MetaConversionsApi\ValueObject\Fbc;
8+
use Setono\MetaConversionsApi\ValueObject\Fbp;
9+
10+
final class ResolvedCookies
11+
{
12+
/**
13+
* Null when the request had no fbclid and no valid _fbc cookie
14+
*/
15+
public readonly ?Fbc $fbc;
16+
17+
/**
18+
* Null only when the _fbp cookie exists but cannot be represented as a value object
19+
*/
20+
public readonly ?Fbp $fbp;
21+
22+
/**
23+
* The cookies you should set on the response
24+
*
25+
* @var list<Cookie>
26+
*/
27+
public readonly array $cookiesToSet;
28+
29+
/**
30+
* @param list<Cookie> $cookiesToSet
31+
*/
32+
public function __construct(?Fbc $fbc, ?Fbp $fbp, array $cookiesToSet)
33+
{
34+
$this->fbc = $fbc;
35+
$this->fbp = $fbp;
36+
$this->cookiesToSet = $cookiesToSet;
37+
}
38+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\MetaConversionsApi\Cookie;
6+
7+
use PHPUnit\Framework\TestCase;
8+
9+
final class CookieResolverTest extends TestCase
10+
{
11+
/**
12+
* @test
13+
*/
14+
public function it_passes_through_existing_five_segment_cookies_unchanged(): void
15+
{
16+
$fbc = 'fb.1.1657051589577.IwAR1a-b_c.AQECAQMB';
17+
$fbp = 'fb.1.1656874832584.1088522659.AQEAAQMB';
18+
19+
$resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbc' => $fbc, '_fbp' => $fbp]);
20+
21+
self::assertNotNull($resolvedCookies->fbc);
22+
self::assertSame($fbc, $resolvedCookies->fbc->value());
23+
self::assertNotNull($resolvedCookies->fbp);
24+
self::assertSame($fbp, $resolvedCookies->fbp->value());
25+
self::assertSame([], $resolvedCookies->cookiesToSet);
26+
}
27+
28+
/**
29+
* @test
30+
*/
31+
public function it_upgrades_a_four_segment_cookie_with_an_appendix_and_sets_it(): void
32+
{
33+
$fbp = 'fb.1.1656874832584.1088522659';
34+
35+
$resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbp' => $fbp]);
36+
37+
self::assertNotNull($resolvedCookies->fbp);
38+
self::assertMatchesRegularExpression('/^fb\.1\.1656874832584\.1088522659\.[A-Za-z0-9_-]{8}$/', $resolvedCookies->fbp->value());
39+
self::assertNotNull($resolvedCookies->fbp->getAppendix());
40+
41+
$cookie = self::cookie($resolvedCookies, '_fbp');
42+
self::assertSame($resolvedCookies->fbp->value(), $cookie->value);
43+
self::assertSame(90 * 24 * 3600, $cookie->maxAge);
44+
self::assertSame('example.com', $cookie->domain);
45+
}
46+
47+
/**
48+
* @test
49+
*/
50+
public function it_builds_an_fbc_from_the_fbclid_query_parameter(): void
51+
{
52+
$before = (int) floor(microtime(true) * 1000);
53+
$resolvedCookies = (new CookieResolver())->resolve('www.example.com', ['fbclid' => 'IwAR1a-b_c'], []);
54+
$after = (int) ceil(microtime(true) * 1000);
55+
56+
self::assertNotNull($resolvedCookies->fbc);
57+
self::assertSame('IwAR1a-b_c', $resolvedCookies->fbc->getClickId());
58+
self::assertSame(1, $resolvedCookies->fbc->getSubdomainIndex());
59+
self::assertGreaterThanOrEqual($before, $resolvedCookies->fbc->getCreationTime());
60+
self::assertLessThanOrEqual($after, $resolvedCookies->fbc->getCreationTime());
61+
self::assertNotNull($resolvedCookies->fbc->getAppendix());
62+
63+
self::assertSame($resolvedCookies->fbc->value(), self::cookie($resolvedCookies, '_fbc')->value);
64+
}
65+
66+
/**
67+
* @test
68+
*/
69+
public function it_generates_an_fbp_when_the_request_has_none(): void
70+
{
71+
$resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], []);
72+
73+
self::assertNull($resolvedCookies->fbc);
74+
self::assertNotNull($resolvedCookies->fbp);
75+
self::assertSame(1, $resolvedCookies->fbp->getSubdomainIndex());
76+
self::assertNotNull($resolvedCookies->fbp->getAppendix());
77+
78+
self::assertSame($resolvedCookies->fbp->value(), self::cookie($resolvedCookies, '_fbp')->value);
79+
}
80+
81+
/**
82+
* @test
83+
*/
84+
public function it_regenerates_the_fbp_when_the_existing_cookie_has_an_invalid_appendix(): void
85+
{
86+
// a two character appendix must be one of the language tokens Meta supports, so ZZ is invalid
87+
$resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbp' => 'fb.1.1656874832584.1088522659.ZZ']);
88+
89+
self::assertNotNull($resolvedCookies->fbp);
90+
self::assertGreaterThan(1656874832584, $resolvedCookies->fbp->getCreationTime());
91+
92+
self::assertSame($resolvedCookies->fbp->value(), self::cookie($resolvedCookies, '_fbp')->value);
93+
}
94+
95+
/**
96+
* @test
97+
*/
98+
public function it_returns_null_for_a_cookie_that_cannot_be_represented_as_a_value_object(): void
99+
{
100+
// Meta's parameter builder only validates the segment count, so these pass through it,
101+
// but they are not valid fbc/fbp values
102+
$resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbc' => 'a.b.c.d', '_fbp' => 'e.f.g.h']);
103+
104+
self::assertNull($resolvedCookies->fbc);
105+
self::assertNull($resolvedCookies->fbp);
106+
self::assertStringStartsWith('a.b.c.d.', self::cookie($resolvedCookies, '_fbc')->value);
107+
self::assertStringStartsWith('e.f.g.h.', self::cookie($resolvedCookies, '_fbp')->value);
108+
}
109+
110+
/**
111+
* @test
112+
*/
113+
public function it_uses_the_given_domains_to_derive_the_cookie_domain_and_subdomain_index(): void
114+
{
115+
$resolvedCookies = (new CookieResolver(['example.co.uk']))->resolve('shop.example.co.uk', [], []);
116+
117+
self::assertNotNull($resolvedCookies->fbp);
118+
self::assertSame(2, $resolvedCookies->fbp->getSubdomainIndex());
119+
self::assertSame('example.co.uk', self::cookie($resolvedCookies, '_fbp')->domain);
120+
}
121+
122+
private static function cookie(ResolvedCookies $resolvedCookies, string $name): Cookie
123+
{
124+
foreach ($resolvedCookies->cookiesToSet as $cookie) {
125+
if ($cookie->name === $name) {
126+
return $cookie;
127+
}
128+
}
129+
130+
self::fail(sprintf('No cookie named "%s" was set', $name));
131+
}
132+
}

0 commit comments

Comments
 (0)