From 5d24b86bac03a5156ebf2d0586d91a807a2297a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 7 Sep 2026 14:15:38 +0200 Subject: [PATCH 1/2] Parse the fbp and fbc cookies the way Meta writes them today fromString() on both value objects rejected two shapes that Meta itself produces: - click ids containing - or _, which is what base64url fbclid values look like, because the pattern only allowed [a-zA-Z0-9] - the trailing appendix segment that facebook/capi-param-builder-php and the browser pixel append, e.g. fb.1.1788781160733.IwAR1a-b_c.AQECAQMB Both threw InvalidArgumentException, so a caller reading the cookie the pixel had just written got nothing back. For fbp that is worse than for fbc: the usual fallback is to generate a new value, so the server side events stopped matching the browser ones. Both patterns now accept those shapes, and the appendix is preserved so a value read from a cookie is written back byte for byte. --- src/ValueObject/Fb.php | 44 ++++++++++++++++++++++++++ src/ValueObject/Fbc.php | 19 +++++++++-- src/ValueObject/Fbp.php | 8 +++-- tests/ValueObject/FbTest.php | 59 +++++++++++++++++++++++++++++++++++ tests/ValueObject/FbcTest.php | 57 ++++++++++++++++++++++++++++++++- tests/ValueObject/FbpTest.php | 37 ++++++++++++++++++++++ 6 files changed, 217 insertions(+), 7 deletions(-) diff --git a/src/ValueObject/Fb.php b/src/ValueObject/Fb.php index 060bcea..647d550 100644 --- a/src/ValueObject/Fb.php +++ b/src/ValueObject/Fb.php @@ -16,11 +16,27 @@ abstract class Fb private int $subdomainIndex = self::SUBDOMAIN_INDEX_FACEBOOK_COM; + /** + * The character set and the two lengths Meta's own parameter builder uses for the appendix + * + * @see https://github.com/facebook/capi-param-builder-php `APPENDIX_LENGTH_V1` and `APPENDIX_LENGTH_V2` + */ + private const REGEXP_APPENDIX = '/^[A-Za-z0-9_-]{2,8}$/'; + /** * Creation time is the UNIX time since epoch in milliseconds when the _fbp cookie was saved */ private int $creationTime; + /** + * The trailing segment Meta appends to the cookie value, e.g. the 'AQECAQMB' in + * fb.1.1788781160733.IwAR1a-b_c.AQECAQMB + * + * We do not interpret it, but we do keep it: a value read from a cookie has to be written back unchanged, + * otherwise every request rewrites the cookie into a different shape than the browser pixel expects + */ + private ?string $appendix = null; + public function __construct() { $this->creationTime = (int) ceil(microtime(true) * 1000); @@ -89,6 +105,34 @@ public function getCreationTimeAsDateTime(): \DateTimeImmutable return $dateTime; } + public function getAppendix(): ?string + { + return $this->appendix; + } + + /** + * @return static + */ + public function withAppendix(?string $appendix): self + { + if (null !== $appendix) { + Assert::regex($appendix, self::REGEXP_APPENDIX); + } + + $obj = clone $this; + $obj->appendix = $appendix; + + return $obj; + } + + /** + * Returns the appendix ready to be concatenated onto a value, i.e. '.AQECAQMB' or an empty string + */ + final protected function appendixSuffix(): string + { + return null === $this->appendix ? '' : '.' . $this->appendix; + } + public function getCreationTimeAsSeconds(): int { return (int) ($this->creationTime / 1000); diff --git a/src/ValueObject/Fbc.php b/src/ValueObject/Fbc.php index d4024e0..5cecd32 100644 --- a/src/ValueObject/Fbc.php +++ b/src/ValueObject/Fbc.php @@ -9,7 +9,14 @@ */ final class Fbc extends Fb { - private const REGEXP_FBC = '/^fb\.([012])\.(\d{13})\.([a-zA-Z0-9]+)$/'; + /** + * Click ids are base64url, so they contain - and _, and Meta appends an optional trailing segment + * + * Must match strings like: + * - fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA + * - fb.1.1788781160733.IwAR1a-b_c.AQECAQMB + */ + private const REGEXP_FBC = '/^fb\.([012])\.(\d{13})\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]{2,8}))?$/'; private string $clickId; @@ -22,7 +29,6 @@ public function __construct(string $clickId) public static function fromString(string $value): self { - // Must match strings like: fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA if (preg_match(self::REGEXP_FBC, $value, $matches) !== 1) { throw new \InvalidArgumentException(sprintf( 'The value "%s" didn\'t match the expected pattern for fbc: "%s"', @@ -34,12 +40,19 @@ public static function fromString(string $value): self return (new self($matches[3])) ->withSubdomainIndex((int) $matches[1]) ->withCreationTime((int) $matches[2]) + ->withAppendix(($matches[4] ?? '') === '' ? null : $matches[4]) ; } public function value(): string { - return sprintf('fb.%d.%d.%s', $this->getSubdomainIndex(), $this->getCreationTime(), $this->clickId); + return sprintf( + 'fb.%d.%d.%s%s', + $this->getSubdomainIndex(), + $this->getCreationTime(), + $this->clickId, + $this->appendixSuffix(), + ); } /** diff --git a/src/ValueObject/Fbp.php b/src/ValueObject/Fbp.php index 3786289..5b1af29 100644 --- a/src/ValueObject/Fbp.php +++ b/src/ValueObject/Fbp.php @@ -21,9 +21,9 @@ public function __construct() public static function fromString(string $value): self { - // Must match something like this: fb.1.1656874832584.1088522659 + // Must match something like this: fb.1.1656874832584.1088522659 or fb.1.1656874832584.1088522659.AQEAAQMB // NOTICE we match for 13 digits for the creation time. That number will be 14 digits in year 2286, so I guess it's safe to test for a specific number of digits ;) - if (preg_match('/^fb\.([012])\.(\d{13})\.(\d+)$/', $value, $matches) !== 1) { + if (preg_match('/^fb\.([012])\.(\d{13})\.(\d+)(?:\.([A-Za-z0-9_-]{2,8}))?$/', $value, $matches) !== 1) { throw new \InvalidArgumentException(sprintf('The value "%s" didn\'t match the expected pattern for fbp', $value)); } @@ -31,16 +31,18 @@ public static function fromString(string $value): self ->withSubdomainIndex((int) $matches[1]) ->withCreationTime((int) $matches[2]) ->withRandomNumber((int) $matches[3]) + ->withAppendix(($matches[4] ?? '') === '' ? null : $matches[4]) ; } public function value(): string { return sprintf( - 'fb.%d.%d.%d', + 'fb.%d.%d.%d%s', $this->getSubdomainIndex(), $this->getCreationTime(), $this->getRandomNumber(), + $this->appendixSuffix(), ); } diff --git a/tests/ValueObject/FbTest.php b/tests/ValueObject/FbTest.php index ea4bac6..78f6793 100644 --- a/tests/ValueObject/FbTest.php +++ b/tests/ValueObject/FbTest.php @@ -131,4 +131,63 @@ public function it_rejects_a_creation_time_that_is_neither_an_integer_nor_a_date (new Fbp())->withCreationTime('1656874832584'); // @phpstan-ignore argument.type } + + /** + * @test + */ + public function it_has_no_appendix_by_default(): void + { + $fb = new Fbp(); + + self::assertNull($fb->getAppendix()); + self::assertStringEndsWith((string) $fb->getRandomNumber(), $fb->value()); + } + + /** + * @test + */ + public function it_has_an_immutable_appendix_setter(): void + { + $fb = new Fbp(); + $newFb = $fb->withAppendix('AQECAQMB'); + + self::assertNotSame($fb, $newFb); + self::assertNull($fb->getAppendix()); + self::assertSame('AQECAQMB', $newFb->getAppendix()); + self::assertStringEndsWith('.AQECAQMB', $newFb->value()); + } + + /** + * @test + */ + public function it_removes_the_appendix(): void + { + $fb = (new Fbp())->withAppendix('AQECAQMB')->withAppendix(null); + + self::assertNull($fb->getAppendix()); + self::assertStringEndsNotWith('.AQECAQMB', $fb->value()); + } + + /** + * @test + * + * @dataProvider invalidAppendixes + */ + public function it_rejects_an_invalid_appendix(string $appendix): void + { + $this->expectException(\InvalidArgumentException::class); + + (new Fbp())->withAppendix($appendix); + } + + /** + * @return \Generator + */ + public static function invalidAppendixes(): \Generator + { + yield 'empty' => ['']; + yield 'too short' => ['A']; + yield 'too long' => ['AQECAQMBX']; + yield 'illegal character' => ['AQECAQM.']; + } } diff --git a/tests/ValueObject/FbcTest.php b/tests/ValueObject/FbcTest.php index ce7e1ab..8c073e1 100644 --- a/tests/ValueObject/FbcTest.php +++ b/tests/ValueObject/FbcTest.php @@ -81,6 +81,61 @@ public static function wrongInputs(): \Generator { yield ['wrong input']; yield ['afb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA']; - yield ['fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA_']; + yield 'click id with an illegal character' => ['fb.1.1657051589577.IwAR0rmfgHgx!']; + yield 'empty click id' => ['fb.1.1657051589577.']; + yield 'appendix too short' => ['fb.1.1657051589577.IwAR0rmfgHgx.A']; + yield 'appendix too long' => ['fb.1.1657051589577.IwAR0rmfgHgx.AQECAQMBX']; + yield 'six segments' => ['fb.1.1657051589577.IwAR0rmfgHgx.AQ.AQ']; + } + + /** + * Real click ids are base64url, so they contain - and _ + * + * @test + */ + public function it_parses_a_base64url_click_id(): void + { + $str = 'fb.1.1657051589577.IwZXh0bgNhZW0CMTAAAR-uK_5w'; + $fbc = Fbc::fromString($str); + + self::assertSame('IwZXh0bgNhZW0CMTAAAR-uK_5w', $fbc->getClickId()); + self::assertSame($str, $fbc->value()); + } + + /** + * Meta's own parameter builder writes a trailing appendix segment, and so does the browser pixel. We do not + * interpret it, but a value read from a cookie has to be written back unchanged + * + * @test + * + * @dataProvider valuesWithAnAppendix + */ + public function it_round_trips_an_appendix(string $str, string $appendix): void + { + $fbc = Fbc::fromString($str); + + self::assertSame($appendix, $fbc->getAppendix()); + self::assertSame('IwAR1a-b_c', $fbc->getClickId()); + self::assertSame($str, $fbc->value()); + } + + /** + * @return \Generator + */ + public static function valuesWithAnAppendix(): \Generator + { + yield 'two characters' => ['fb.1.1788781160733.IwAR1a-b_c.AQ', 'AQ']; + yield 'eight characters' => ['fb.1.1788781160733.IwAR1a-b_c.AQECAQMB', 'AQECAQMB']; + } + + /** + * @test + */ + public function it_keeps_the_appendix_through_the_immutable_setters(): void + { + $fbc = Fbc::fromString('fb.1.1657051589577.IwAR1a-b_c.AQECAQMB'); + + self::assertSame('AQECAQMB', $fbc->withClickId('Other')->getAppendix()); + self::assertSame('fb.2.1657051589577.IwAR1a-b_c.AQECAQMB', $fbc->withSubdomainIndex(2)->value()); } } diff --git a/tests/ValueObject/FbpTest.php b/tests/ValueObject/FbpTest.php index 2b0d0d5..537de3b 100644 --- a/tests/ValueObject/FbpTest.php +++ b/tests/ValueObject/FbpTest.php @@ -63,4 +63,41 @@ public static function wrongInputs(): \Generator yield ['fb.1.1656874832584.1088522659a']; yield ['afb.1.1656874832584.1088522659']; } + + /** + * Meta's own parameter builder writes a trailing appendix segment, and so does the browser pixel. Rejecting + * such a cookie means generating a brand new fbp on every request, which stops the server side events from + * matching the browser ones + * + * @test + * + * @dataProvider valuesWithAnAppendix + */ + public function it_round_trips_an_appendix(string $str, string $appendix): void + { + $fbp = Fbp::fromString($str); + + self::assertSame($appendix, $fbp->getAppendix()); + self::assertSame(1088522659, $fbp->getRandomNumber()); + self::assertSame($str, $fbp->value()); + } + + /** + * @return \Generator + */ + public static function valuesWithAnAppendix(): \Generator + { + yield 'two characters' => ['fb.1.1656874832584.1088522659.AQ', 'AQ']; + yield 'eight characters' => ['fb.1.1656874832584.1088522659.AQEAAQMB', 'AQEAAQMB']; + } + + /** + * @test + */ + public function it_keeps_the_appendix_through_the_immutable_setters(): void + { + $fbp = Fbp::fromString('fb.1.1656874832584.1088522659.AQEAAQMB'); + + self::assertSame('fb.1.1656874832584.123123.AQEAAQMB', $fbp->withRandomNumber(123123)->value()); + } } From 5b4a4bafaa41f42d6de91c3656fe482bb73a48be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 7 Sep 2026 14:32:18 +0200 Subject: [PATCH 2/2] Inline the appendix concatenation in value() instead of a dedicated helper --- src/ValueObject/Fb.php | 8 -------- src/ValueObject/Fbc.php | 15 ++++++++------- src/ValueObject/Fbp.php | 15 ++++++++------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/ValueObject/Fb.php b/src/ValueObject/Fb.php index 647d550..f895c21 100644 --- a/src/ValueObject/Fb.php +++ b/src/ValueObject/Fb.php @@ -125,14 +125,6 @@ public function withAppendix(?string $appendix): self return $obj; } - /** - * Returns the appendix ready to be concatenated onto a value, i.e. '.AQECAQMB' or an empty string - */ - final protected function appendixSuffix(): string - { - return null === $this->appendix ? '' : '.' . $this->appendix; - } - public function getCreationTimeAsSeconds(): int { return (int) ($this->creationTime / 1000); diff --git a/src/ValueObject/Fbc.php b/src/ValueObject/Fbc.php index 5cecd32..46872f5 100644 --- a/src/ValueObject/Fbc.php +++ b/src/ValueObject/Fbc.php @@ -46,13 +46,14 @@ public static function fromString(string $value): self public function value(): string { - return sprintf( - 'fb.%d.%d.%s%s', - $this->getSubdomainIndex(), - $this->getCreationTime(), - $this->clickId, - $this->appendixSuffix(), - ); + $value = sprintf('fb.%d.%d.%s', $this->getSubdomainIndex(), $this->getCreationTime(), $this->clickId); + + $appendix = $this->getAppendix(); + if (null !== $appendix) { + $value .= '.' . $appendix; + } + + return $value; } /** diff --git a/src/ValueObject/Fbp.php b/src/ValueObject/Fbp.php index 5b1af29..abe856b 100644 --- a/src/ValueObject/Fbp.php +++ b/src/ValueObject/Fbp.php @@ -37,13 +37,14 @@ public static function fromString(string $value): self public function value(): string { - return sprintf( - 'fb.%d.%d.%d%s', - $this->getSubdomainIndex(), - $this->getCreationTime(), - $this->getRandomNumber(), - $this->appendixSuffix(), - ); + $value = sprintf('fb.%d.%d.%d', $this->getSubdomainIndex(), $this->getCreationTime(), $this->getRandomNumber()); + + $appendix = $this->getAppendix(); + if (null !== $appendix) { + $value .= '.' . $appendix; + } + + return $value; } public function getRandomNumber(): int