Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions composer-dependency-analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
;
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions phpstan.dist.neon
Original file line number Diff line number Diff line change
@@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/Cookie/Cookie.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApi\Cookie;

/**
* A cookie that should be set on the response, e.g. with setcookie() or your framework's response API
*/
final class Cookie
{
public readonly string $name;

public readonly string $value;

/**
* The max age in seconds
*/
public readonly int $maxAge;

/**
* The registrable domain the cookie should be set on, e.g. 'example.com'. Null if it could not be derived
*/
public readonly ?string $domain;

public function __construct(string $name, string $value, int $maxAge, ?string $domain)
{
$this->name = $name;
$this->value = $value;
$this->maxAge = $maxAge;
$this->domain = $domain;
}
}
96 changes: 96 additions & 0 deletions src/Cookie/CookieResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApi\Cookie;

use FacebookAds\CookieSettings;
use FacebookAds\ETLDPlus1Resolver;
use FacebookAds\ParamBuilder;
use Setono\MetaConversionsApi\ValueObject\Fbc;
use Setono\MetaConversionsApi\ValueObject\Fbp;
use Webmozart\Assert\Assert;

/**
* Resolves the _fbc/_fbp cookies for a request by delegating to Meta's own parameter builder
* (facebook/capi-param-builder-php), so this SDK does not have to replicate how Meta reads,
* refreshes and writes those cookies: existing values are validated and upgraded to the current
* format, a new fbc is built from the fbclid query parameter, and an fbp is generated when the
* request has none
*/
final class CookieResolver implements CookieResolverInterface
{
/** @var list<string>|ETLDPlus1Resolver|null */
private array|ETLDPlus1Resolver|null $domains;

/**
* @param list<string>|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);

Check warning on line 48 in src/Cookie/CookieResolver.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ $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();

$fbp = $paramBuilder->getFbp();
Assert::nullOrString($fbp);

Check warning on line 51 in src/Cookie/CookieResolver.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ $fbc = $paramBuilder->getFbc(); Assert::nullOrString($fbc); $fbp = $paramBuilder->getFbp(); - Assert::nullOrString($fbp); + $cookieSettings = $paramBuilder->getCookiesToSet(); Assert::isArray($cookieSettings); $cookiesToSet = [];

$cookieSettings = $paramBuilder->getCookiesToSet();
Assert::isArray($cookieSettings);

Check warning on line 54 in src/Cookie/CookieResolver.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ $fbp = $paramBuilder->getFbp(); Assert::nullOrString($fbp); $cookieSettings = $paramBuilder->getCookiesToSet(); - Assert::isArray($cookieSettings); + $cookiesToSet = []; foreach ($cookieSettings as $cookieSetting) { Assert::isInstanceOf($cookieSetting, CookieSettings::class);

$cookiesToSet = [];
foreach ($cookieSettings as $cookieSetting) {
Assert::isInstanceOf($cookieSetting, CookieSettings::class);

Check warning on line 58 in src/Cookie/CookieResolver.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ 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::string($cookieSetting->name);

Check warning on line 59 in src/Cookie/CookieResolver.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ $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);
Assert::string($cookieSetting->value);

Check warning on line 60 in src/Cookie/CookieResolver.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ 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);
Assert::integer($cookieSetting->max_age);

Check warning on line 61 in src/Cookie/CookieResolver.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ 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); }
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;
}
}
}
27 changes: 27 additions & 0 deletions src/Cookie/CookieResolverInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApi\Cookie;

interface CookieResolverInterface
{
/**
* Takes the raw ingredients of an HTTP request and resolves the Meta cookies for it
*
* @param string $host the HTTP host of the current request, e.g. 'www.example.com'
* @param array<array-key, mixed> $query the query parameters of the current request, e.g. $_GET
* @param array<array-key, mixed> $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;
}
38 changes: 38 additions & 0 deletions src/Cookie/ResolvedCookies.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApi\Cookie;

use Setono\MetaConversionsApi\ValueObject\Fbc;
use Setono\MetaConversionsApi\ValueObject\Fbp;

final class ResolvedCookies
{
/**
* Null when the request had no fbclid and no valid _fbc cookie
*/
public readonly ?Fbc $fbc;

/**
* Null only when the _fbp cookie exists but cannot be represented as a value object
*/
public readonly ?Fbp $fbp;

/**
* The cookies you should set on the response
*
* @var list<Cookie>
*/
public readonly array $cookiesToSet;

/**
* @param list<Cookie> $cookiesToSet
*/
public function __construct(?Fbc $fbc, ?Fbp $fbp, array $cookiesToSet)
{
$this->fbc = $fbc;
$this->fbp = $fbp;
$this->cookiesToSet = $cookiesToSet;
}
}
132 changes: 132 additions & 0 deletions tests/Cookie/CookieResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApi\Cookie;

use PHPUnit\Framework\TestCase;

final class CookieResolverTest extends TestCase
{
/**
* @test
*/
public function it_passes_through_existing_five_segment_cookies_unchanged(): void
{
$fbc = 'fb.1.1657051589577.IwAR1a-b_c.AQECAQMB';
$fbp = 'fb.1.1656874832584.1088522659.AQEAAQMB';

$resolvedCookies = (new CookieResolver())->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));
}
}
Loading