From d2b3b63dd93c2adc6f2aef8d380de4b7676b0da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 15 Jun 2026 10:24:33 +0200 Subject: [PATCH 1/8] Replace Psalm with PHPStan and upgrade dependencies - Bump facebook/php-business-sdk from ^22.0 to ^25.0 (Graph API v25.0) - Replace setono/code-quality-pack with its individual dev tools pinned to the latest versions that still support PHP 8.1 (PHPStan ^2.1, PHPUnit ^10.5, Infection ^0.29, ECS via sylius-labs/coding-standard, etc.) - Swap Psalm for PHPStan (level max): drop psalm.xml, add phpstan.dist.neon, update the analyse script and remove @psalm-suppress annotations - Fix the resulting level-max findings: remove dead responseFactory from Client, add precise iterable/array types, narrow mixed in ErrorResponse, refactor getPayload() so its array contract is provable - Make implicitly-nullable params explicit (8.4 deprecation) and migrate phpunit.xml.dist to the PHPUnit 10 schema - Add tests (25 -> 39): Client error path + test_event_code, ErrorResponse parsing, Content payload, FbqGenerator branches, Event::isCustom, Fbc immutable creation time - Add PHP 8.4 to the CI matrices --- .github/workflows/build.yaml | 3 ++ CLAUDE.md | 59 ++++++++++++++++++++ composer.json | 25 ++++++--- phpstan.dist.neon | 7 +++ phpunit.xml.dist | 14 ++--- psalm.xml | 23 -------- src/Client/Client.php | 17 ------ src/Client/ErrorResponse.php | 8 ++- src/Event/Content.php | 8 +-- src/Event/Event.php | 1 + src/Event/Parameters.php | 29 +++++++--- src/Generator/FbqGenerator.php | 5 ++ src/Generator/FbqGeneratorInterface.php | 1 + src/Pixel/Pixel.php | 2 +- src/ValueObject/Fb.php | 1 - tests/Client/ClientTest.php | 55 ++++++++++++++++++- tests/Client/ErrorResponseTest.php | 71 +++++++++++++++++++++++++ tests/Client/LiveClientTest.php | 10 ++-- tests/Event/ContentTest.php | 38 +++++++++++++ tests/Event/EventTest.php | 22 +++++--- tests/Generator/FbqGeneratorTest.php | 48 ++++++++++++++++- tests/ValueObject/FbcTest.php | 15 +++++- tests/ValueObject/FbpTest.php | 2 +- 23 files changed, 376 insertions(+), 88 deletions(-) create mode 100644 CLAUDE.md create mode 100644 phpstan.dist.neon delete mode 100644 psalm.xml create mode 100644 tests/Client/ErrorResponseTest.php create mode 100644 tests/Event/ContentTest.php diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 31eb1a1..14e89f4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -56,6 +56,7 @@ jobs: - "8.1" - "8.2" - "8.3" + - "8.4" dependencies: - "lowest" @@ -92,6 +93,7 @@ jobs: - "8.1" - "8.2" - "8.3" + - "8.4" dependencies: - "lowest" @@ -127,6 +129,7 @@ jobs: - "8.1" - "8.2" - "8.3" + - "8.4" dependencies: - "lowest" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..61d2c92 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A PHP SDK providing value objects and a client for Meta's (Facebook) [Conversions API](https://developers.facebook.com/docs/marketing-api/conversions-api). It is a library (`setono/meta-conversions-api-php-sdk`), not an application — there is no runnable entry point. Requires PHP >= 8.1. + +## Commands + +Composer scripts (defined in `composer.json`): + +- `composer phpunit` — run the test suite (PHPUnit 10) +- `composer analyse` — PHPStan static analysis (`phpstan.dist.neon`: `level: max`, analysed against PHP 8.1) +- `composer check-style` / `composer fix-style` — ECS coding-standard check / autofix +- `vendor/bin/infection` — mutation testing (thresholds: minMsi 61.74, minCoveredMsi 76.77). Needs a coverage driver (pcov or Xdebug); CI uses pcov. With neither installed locally you'll get "No code coverage driver available". +- `vendor/bin/composer-dependency-analyser` — verify declared composer deps match actual usage + +The dev tooling (PHPStan + extensions, ECS via `sylius-labs/coding-standard`, PHPUnit, Infection, Rector, composer-normalize, composer-dependency-analyser) is listed directly in `require-dev` rather than pulled in through the `setono/code-quality-pack` meta-package. The pack's current major requires PHP >= 8.2; inlining the tools keeps the whole toolchain runnable on PHP 8.1. When bumping a tool, pick the latest version that still supports PHP 8.1 (e.g. PHPUnit stays on `^10.5`, Infection on `^0.29`). + +Run a single test by file or filter: + +```bash +vendor/bin/phpunit tests/Event/UserTest.php +vendor/bin/phpunit --filter it_sends_event +``` + +Tests use the `@test` annotation with `snake_case` method names (no `test` prefix). + +CI (`.github/workflows/build.yaml`) runs coding standards, dependency analysis, PHPStan, and PHPUnit against PHP 8.1–8.4 on both `lowest` and `highest` dependency versions, so check lowest-version compatibility when touching dependencies. A separate workflow runs Roave's backwards-compatibility check on PRs — this is a public library, so avoid BC breaks to the public API. + +### LiveClientTest + +`tests/Client/LiveClientTest.php` hits the real Meta API. It self-skips unless the env vars in `phpunit.xml.dist` are set (`PIXEL_ID`, `ACCESS_TOKEN`, `TEST_EVENT_CODE`, `URL`, `EMAIL`). Copy `phpunit.xml.dist` to `phpunit.xml` and fill them in to run it. + +## Architecture + +The core abstraction is the serialization pipeline in `src/Event/Parameters.php`. Everything sent to Meta flows through it. + +**`Parameters` (abstract base)** — each subclass implements `getMapping(string $context): array`, returning Meta's snake_case field names mapped to the object's (camelCase) PHP property values. `getPayload()` runs that mapping through `normalize()`, which recursively: +1. formats `DateTimeInterface` as `Ymd` and casts `Stringable` to string, +2. normalizes fields listed in `getNormalizedFields()` via `FacebookAds\Object\ServerSide\Normalizer`, +3. hashes fields listed in `getHashedFields()` via `FacebookAds\Object\ServerSide\Util::hash` (SHA-256 — this is how PII like email/phone is protected), +4. recurses into nested `Parameters` objects (calling their `getPayload()`), +5. strips empty values (`null`, `''`, `[]`) so they aren't sent. + +So to add a field: add the public property, map it in `getMapping()`, and register it in `getNormalizedFields()`/`getHashedFields()` if Meta requires it. The lists of which fields normalize/hash mirror the corresponding `FacebookAds\Object\ServerSide\*` classes (see the `@see` annotations) — keep them in sync with that SDK. + +**Two payload contexts** (`PAYLOAD_CONTEXT_SERVER` vs `PAYLOAD_CONTEXT_BROWSER`). The same objects serialize differently depending on whether they're sent server-side via the Conversions API or rendered into a client-side `fbq()` call. `User::getMapping()` strips server-only fields (IP, user agent, fbc, fbp) in browser context. + +**`Parameters` subclasses:** `Event` (the aggregate root — holds `User $userData`, `Custom $customData`, a list of `Pixel`, plus `metadata` for app-internal use that is never sent), `User` (customer matching data), `Custom` (event-specific data like value/currency/contents). `Event` auto-generates `eventId` (random, for [deduplication](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event#event-id)) and `eventTime` in its constructor. `Event` is intentionally **not** `final` so consumers can subclass it into domain-specific events; the other data objects are `final`. + +**`Client` (`src/Client/Client.php`)** — `sendEvent()` serializes the event once, then POSTs it (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per associated pixel (each pixel carries its own access token). Non-200 responses throw `ClientException` built from `ErrorResponse`. HTTP is fully PSR-based: PSR-18 client and PSR-17 factories are auto-discovered via `php-http/discovery` but can be injected with `setHttpClient()` / `setRequestFactory()` / etc. The client is `LoggerAware` and defaults to `NullLogger`. + +**`FbqGenerator` (`src/Generator/FbqGenerator.php`)** — the client-side counterpart. Generates the `fbq('init', ...)` / `fbq('track', ...)` JavaScript snippets, using the browser-context payload and reusing the same `eventId` so server and browser events deduplicate. `Event::isCustom()` decides between `track` and `trackCustom`. + +**Value objects (`src/ValueObject/`)** — `Fbc`/`Fbp` (extending `Fb`) model the `_fbc`/`_fbp` cookie values with `fromString()` validation and `value()` serialization; assignable to `User::$fbc`/`$fbp` as either the typed object or a raw string. + +The `facebook/php-business-sdk` dependency is used only for `Normalizer`, `Util::hash`, and `ApiConfig::APIVersion` (the API version is pinned to whatever that package ships). diff --git a/composer.json b/composer.json index c33a154..014c86a 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "require": { "php": ">=8.1", "ext-json": "*", - "facebook/php-business-sdk": "^22.0", + "facebook/php-business-sdk": "^25.0", "php-http/discovery": "^1.20", "psr/http-client": "^1.0", "psr/http-client-implementation": "*", @@ -22,12 +22,20 @@ "webmozart/assert": "^1.11" }, "require-dev": { - "infection/infection": "^0.26.21", + "ergebnis/composer-normalize": "^2.50", + "infection/infection": "^0.29", + "jangregor/phpstan-prophecy": "^2.3", "nyholm/psr7": "^1.8", - "phpunit/phpunit": "^9.6", - "psalm/plugin-phpunit": "^0.19", - "setono/code-quality-pack": "^2.9", - "shipmonk/composer-dependency-analyser": "^1.8.2", + "phpspec/prophecy-phpunit": "^2.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4", + "shipmonk/composer-dependency-analyser": "^1.8", + "sylius-labs/coding-standard": "^4.5", "symfony/http-client": "^6.4 || ^7.0" }, "prefer-stable": true, @@ -46,12 +54,13 @@ "dealerdirect/phpcodesniffer-composer-installer": false, "ergebnis/composer-normalize": true, "infection/extension-installer": true, - "php-http/discovery": false + "php-http/discovery": false, + "phpstan/extension-installer": true }, "sort-packages": true }, "scripts": { - "analyse": "psalm", + "analyse": "phpstan analyse", "check-style": "ecs check", "fix-style": "ecs check --fix", "phpunit": "phpunit" diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..c09b691 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,7 @@ +parameters: + level: max + phpVersion: 80100 + treatPhpDocTypesAsCertain: false + paths: + - src + - tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 435518b..0bd36fe 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,17 +1,17 @@ - - - src/ - - + colors="true"> - + tests + + + src + + diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 50c840d..0000000 --- a/psalm.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/Client/Client.php b/src/Client/Client.php index 9675716..f563e08 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -9,7 +9,6 @@ use Http\Discovery\Psr18ClientDiscovery; use Psr\Http\Client\ClientInterface as HttpClientInterface; use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; @@ -23,8 +22,6 @@ final class Client implements ClientInterface, LoggerAwareInterface private ?RequestFactoryInterface $requestFactory = null; - private ?ResponseFactoryInterface $responseFactory = null; - private ?StreamFactoryInterface $streamFactory = null; private LoggerInterface $logger; @@ -101,20 +98,6 @@ public function setRequestFactory(RequestFactoryInterface $requestFactory): void $this->requestFactory = $requestFactory; } - private function getResponseFactory(): ResponseFactoryInterface - { - if (null === $this->responseFactory) { - $this->responseFactory = Psr17FactoryDiscovery::findResponseFactory(); - } - - return $this->responseFactory; - } - - public function setResponseFactory(ResponseFactoryInterface $responseFactory): void - { - $this->responseFactory = $responseFactory; - } - private function getStreamFactory(): StreamFactoryInterface { if (null === $this->streamFactory) { diff --git a/src/Client/ErrorResponse.php b/src/Client/ErrorResponse.php index 5477d67..06c98f1 100644 --- a/src/Client/ErrorResponse.php +++ b/src/Client/ErrorResponse.php @@ -51,12 +51,16 @@ public static function fromJson(string $json): self try { Assert::isArray($data); + Assert::keyExists($data, 'error'); - if (!isset($data['error']['message'], $data['error']['type'], $data['error']['code'], $data['error']['fbtrace_id'])) { + $error = $data['error']; + Assert::isArray($error); + + if (!isset($error['message'], $error['type'], $error['code'], $error['fbtrace_id'])) { throw ClientException::invalidResponseFormat($json); } - ['message' => $message, 'type' => $type, 'code' => $code, 'fbtrace_id' => $traceId] = $data['error']; + ['message' => $message, 'type' => $type, 'code' => $code, 'fbtrace_id' => $traceId] = $error; Assert::string($message); Assert::string($type); diff --git a/src/Event/Content.php b/src/Event/Content.php index 328280b..e1e6407 100644 --- a/src/Event/Content.php +++ b/src/Event/Content.php @@ -15,10 +15,10 @@ final class Content extends Parameters public ?string $deliveryCategory; public function __construct( - string $id = null, - int $quantity = null, - float $itemPrice = null, - string $deliveryCategory = null, + ?string $id = null, + ?int $quantity = null, + ?float $itemPrice = null, + ?string $deliveryCategory = null, ) { $this->id = $id; $this->quantity = $quantity; diff --git a/src/Event/Event.php b/src/Event/Event.php index 3224d82..ff1797a 100644 --- a/src/Event/Event.php +++ b/src/Event/Event.php @@ -92,6 +92,7 @@ class Event extends Parameters public ?string $actionSource = null; + /** @var list */ public array $dataProcessingOptions = []; public ?int $dataProcessingOptionsCountry = null; diff --git a/src/Event/Parameters.php b/src/Event/Parameters.php index 402bd83..c3e18e2 100644 --- a/src/Event/Parameters.php +++ b/src/Event/Parameters.php @@ -17,13 +17,17 @@ abstract class Parameters /** * This method returns an array representation of the object ready * to be sent to Meta/Facebook, i.e. it's both normalized and hashed + * + * @return array */ public function getPayload(string $context = self::PAYLOAD_CONTEXT_SERVER): array { - $payload = self::normalize($this->getMapping($context)); - Assert::isArray($payload); + $payload = []; + foreach ($this->getMapping($context) as $field => $value) { + $payload[$field] = $value instanceof self ? $value->getPayload() : self::normalize($value, $field); + } - return $payload; + return self::filterEmptyValues($payload); } /** @@ -53,9 +57,9 @@ protected static function getHashedFields(): array /** * @param mixed $data * - * @return array|string|float|int|bool|null + * @return array|string|float|int|bool|null */ - private static function normalize($data, string $field = null) + private static function normalize($data, ?string $field = null) { if (null === $data) { return null; @@ -98,7 +102,20 @@ private static function normalize($data, string $field = null) } unset($datum); - // this will filter values we don't want to send to Meta/Facebook, i.e. nulls, empty strings, and empty arrays + return self::filterEmptyValues($data); + } + + /** + * Filters out the values we don't want to send to Meta/Facebook, i.e. nulls, empty strings, and empty arrays + * + * @template TKey of array-key + * + * @param array $data + * + * @return array + */ + private static function filterEmptyValues(array $data): array + { return array_filter($data, static function ($value): bool { return !(null === $value || '' === $value || [] === $value); }); diff --git a/src/Generator/FbqGenerator.php b/src/Generator/FbqGenerator.php index 89b8b6b..d75312a 100644 --- a/src/Generator/FbqGenerator.php +++ b/src/Generator/FbqGenerator.php @@ -9,6 +9,7 @@ use Psr\Log\NullLogger; use Setono\MetaConversionsApi\Event\Event; use Setono\MetaConversionsApi\Event\Parameters; +use Setono\MetaConversionsApi\Pixel\Pixel; final class FbqGenerator implements FbqGeneratorInterface, LoggerAwareInterface { @@ -19,6 +20,10 @@ public function __construct() $this->logger = new NullLogger(); } + /** + * @param list $pixels + * @param array $userData + */ public function generateInit( array $pixels, array $userData = [], diff --git a/src/Generator/FbqGeneratorInterface.php b/src/Generator/FbqGeneratorInterface.php index 1012873..3b71d3a 100644 --- a/src/Generator/FbqGeneratorInterface.php +++ b/src/Generator/FbqGeneratorInterface.php @@ -13,6 +13,7 @@ interface FbqGeneratorInterface * Will generate the fbq() init call based on the given pixels. By default, this also includes the page view event * * @param list $pixels + * @param array $userData */ public function generateInit( array $pixels, diff --git a/src/Pixel/Pixel.php b/src/Pixel/Pixel.php index 99eb1a2..d967709 100644 --- a/src/Pixel/Pixel.php +++ b/src/Pixel/Pixel.php @@ -18,7 +18,7 @@ final class Pixel */ public ?string $accessToken; - public function __construct(string $id, string $accessToken = null) + public function __construct(string $id, ?string $accessToken = null) { $this->id = $id; $this->accessToken = '' === $accessToken ? null : $accessToken; diff --git a/src/ValueObject/Fb.php b/src/ValueObject/Fb.php index ce94dce..060bcea 100644 --- a/src/ValueObject/Fb.php +++ b/src/ValueObject/Fb.php @@ -71,7 +71,6 @@ public function withCreationTime($creationTime): self $creationTime = (int) $creationTime->format('Uv'); } - /** @psalm-suppress RedundantConditionGivenDocblockType */ Assert::integer($creationTime); Assert::greaterThanEq($creationTime, 1_075_590_000_000); // Facebooks founding date xD Assert::lessThanEq($creationTime, (time() + 1) * 1000); diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index dc7a4e1..c6d1649 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -12,6 +12,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Log\AbstractLogger; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Exception\ClientException; use Setono\MetaConversionsApi\Pixel\Pixel; /** @@ -39,10 +40,58 @@ public function it_sends_event(): void $request = $httpClient->requests[0]; self::assertSame('POST', $request->getMethod()); - self::assertSame('https://graph.facebook.com/v22.0/pixel_id/events', (string) $request->getUri()); + self::assertSame('https://graph.facebook.com/v25.0/pixel_id/events', (string) $request->getUri()); self::assertSame('data=%5B%7B%22event_name%22%3A%22Purchase%22%2C%22event_time%22%3A1658743659123%2C%22event_id%22%3A%22event_id%22%2C%22action_source%22%3A%22website%22%7D%5D', (string) $request->getBody()); } + /** + * @test + */ + public function it_includes_access_token_and_test_event_code_in_the_request(): void + { + $httpClient = new TestHttpClient(); + + $client = new Client(); + $client->setHttpClient($httpClient); + + $event = new Event(Event::EVENT_PURCHASE); + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + $event->testEventCode = 'TEST123'; + $client->sendEvent($event); + + self::assertCount(1, $httpClient->requests); + + $body = (string) $httpClient->requests[0]->getBody(); + self::assertStringContainsString('access_token=access_token', $body); + self::assertStringContainsString('test_event_code=TEST123', $body); + } + + /** + * @test + */ + public function it_throws_an_exception_when_the_response_is_not_successful(): void + { + $responseFactory = new Psr17Factory(); + + $httpClient = new TestHttpClient(); + $httpClient->response = $responseFactory + ->createResponse(400) + ->withBody($responseFactory->createStream( + '{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"fbtrace_id":"trace123"}}', + )); + + $client = new Client(); + $client->setHttpClient($httpClient); + + $event = new Event(Event::EVENT_PURCHASE); + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Invalid parameter'); + + $client->sendEvent($event); + } + /** * @test */ @@ -69,6 +118,8 @@ final class TestHttpClient implements HttpClientInterface /** @var list */ public array $requests = []; + public ?ResponseInterface $response = null; + public function __construct() { $this->responseFactory = new Psr17Factory(); @@ -78,7 +129,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface { $this->requests[] = $request; - return $this->responseFactory->createResponse(); + return $this->response ?? $this->responseFactory->createResponse(); } } diff --git a/tests/Client/ErrorResponseTest.php b/tests/Client/ErrorResponseTest.php new file mode 100644 index 0000000..1f76d17 --- /dev/null +++ b/tests/Client/ErrorResponseTest.php @@ -0,0 +1,71 @@ +json); + self::assertSame('Invalid parameter', $errorResponse->message); + self::assertSame('OAuthException', $errorResponse->type); + self::assertSame(100, $errorResponse->code); + self::assertSame('trace123', $errorResponse->traceId); + } + + /** + * @test + */ + public function it_throws_when_the_response_is_not_valid_json(): void + { + $this->expectException(ClientException::class); + + ErrorResponse::fromJson('this is not json'); + } + + /** + * @test + */ + public function it_throws_when_the_response_is_not_an_array(): void + { + $this->expectException(ClientException::class); + + ErrorResponse::fromJson('100'); + } + + /** + * @test + */ + public function it_throws_when_the_error_key_is_missing(): void + { + $this->expectException(ClientException::class); + + ErrorResponse::fromJson('{"foo":"bar"}'); + } + + /** + * @test + */ + public function it_throws_when_a_field_has_the_wrong_type(): void + { + $this->expectException(ClientException::class); + + // the code field must be an int + ErrorResponse::fromJson('{"error":{"message":"m","type":"t","code":"not-an-int","fbtrace_id":"x"}}'); + } +} diff --git a/tests/Client/LiveClientTest.php b/tests/Client/LiveClientTest.php index 5025c2f..b3ed5e7 100644 --- a/tests/Client/LiveClientTest.php +++ b/tests/Client/LiveClientTest.php @@ -23,9 +23,12 @@ public function it_sends_event(): void try { $testValues = $this->getTestValues(); } catch (\InvalidArgumentException $e) { - $this->markTestSkipped($e->getMessage()); + self::markTestSkipped($e->getMessage()); } + // the test passes as long as sending the event does not throw + $this->expectNotToPerformAssertions(); + $client = new Client(); $event = new Event(Event::EVENT_VIEW_CONTENT); @@ -36,14 +39,10 @@ public function it_sends_event(): void $event->testEventCode = $testValues['testEventCode']; $client->sendEvent($event); - - self::assertTrue(true); } /** * @return array{pixelId: non-empty-string, testEventCode: non-empty-string, accessToken: non-empty-string, url: non-empty-string, email: non-empty-string} - * - * @psalm-suppress InvalidReturnType,MoreSpecificReturnType */ private function getTestValues(): array { @@ -64,7 +63,6 @@ private function getTestValues(): array $values[$variable] = $value; } - /** @psalm-suppress InvalidReturnStatement,LessSpecificReturnStatement */ return $values; } } diff --git a/tests/Event/ContentTest.php b/tests/Event/ContentTest.php new file mode 100644 index 0000000..db706e0 --- /dev/null +++ b/tests/Event/ContentTest.php @@ -0,0 +1,38 @@ + 'product_id', + 'quantity' => 2, + 'item_price' => 99.95, + 'delivery_category' => 'home_delivery', + ], $content->getPayload()); + } + + /** + * @test + */ + public function it_filters_empty_values_from_the_payload(): void + { + $content = new Content('product_id'); + + self::assertSame(['id' => 'product_id'], $content->getPayload()); + } +} diff --git a/tests/Event/EventTest.php b/tests/Event/EventTest.php index b0cc7cb..6cd3204 100644 --- a/tests/Event/EventTest.php +++ b/tests/Event/EventTest.php @@ -41,14 +41,13 @@ public function it_filters(): void $event->eventTime = 123; $event->eventId = 'event_id'; $event->userData->email[] = 'johndoe@example.com'; - $event->userData->email[] = ''; - /** @psalm-suppress InvalidPropertyAssignmentValue */ - $event->userData->email[] = null; - /** @psalm-suppress InvalidPropertyAssignmentValue */ - $event->userData->dateOfBirth[] = \DateTimeImmutable::createFromFormat('Y-m-d', '1986-07-11'); + $event->userData->email[] = ''; // filtered out because it is empty + + $dateOfBirth = \DateTimeImmutable::createFromFormat('Y-m-d', '1986-07-11'); + self::assertNotFalse($dateOfBirth); + $event->userData->dateOfBirth[] = $dateOfBirth; + $event->customData->contents[] = new Content('content_id', 1); - /** @psalm-suppress InvalidPropertyAssignmentValue */ - $event->customData->contents[] = null; self::assertEquals([ 'event_id' => 'event_id', @@ -71,6 +70,15 @@ public function it_filters(): void ], $event->getPayload()); } + /** + * @test + */ + public function it_knows_if_it_is_a_custom_event(): void + { + self::assertFalse((new Event(Event::EVENT_PURCHASE))->isCustom()); + self::assertTrue((new Event('SomeNonStandardEvent'))->isCustom()); + } + /** * @test */ diff --git a/tests/Generator/FbqGeneratorTest.php b/tests/Generator/FbqGeneratorTest.php index be3b476..7fcce24 100644 --- a/tests/Generator/FbqGeneratorTest.php +++ b/tests/Generator/FbqGeneratorTest.php @@ -21,8 +21,10 @@ public function it_generates_init(): void $event->pixels = [new Pixel('111'), new Pixel('222')]; $event->userData->clientIpAddress = '192.168.0.1'; $event->userData->clientUserAgent = 'Chrome'; - /** @psalm-suppress InvalidPropertyAssignmentValue */ - $event->userData->dateOfBirth[] = \DateTimeImmutable::createFromFormat('Y-m-d', '1986-07-11'); + + $dateOfBirth = \DateTimeImmutable::createFromFormat('Y-m-d', '1986-07-11'); + self::assertNotFalse($dateOfBirth); + $event->userData->dateOfBirth[] = $dateOfBirth; $generator = new FbqGenerator(); self::assertSame(<<generateInit($event->pixels, $event->userData->getPayload(Parameters::PAYLOAD_CONTEXT_BROWSER))); } + /** + * @test + */ + public function it_generates_init_without_user_data(): void + { + $generator = new FbqGenerator(); + + self::assertSame( + "fbq('init', '111');fbq('init', '222');fbq('track', 'PageView');", + $generator->generateInit([new Pixel('111'), new Pixel('222')], [], true, false), + ); + } + + /** + * @test + */ + public function it_generates_init_without_the_page_view(): void + { + $generator = new FbqGenerator(); + + self::assertSame( + "fbq('init', '111');", + $generator->generateInit([new Pixel('111')], [], false, false), + ); + } + /** * @test */ @@ -58,4 +86,20 @@ public function it_generates_track(): void EXPECTED , $generator->generateTrack($event)); } + + /** + * @test + */ + public function it_generates_track_for_a_custom_event(): void + { + $event = new Event('MyCustomEvent'); + $event->eventId = 'event_id'; + $event->customData->value = 10.5; + + $generator = new FbqGenerator(); + self::assertSame( + "fbq('trackCustom', 'MyCustomEvent', {\"value\":10.5}, {eventID: 'event_id'});", + $generator->generateTrack($event, false), + ); + } } diff --git a/tests/ValueObject/FbcTest.php b/tests/ValueObject/FbcTest.php index af0f9ad..ce7e1ab 100644 --- a/tests/ValueObject/FbcTest.php +++ b/tests/ValueObject/FbcTest.php @@ -50,6 +50,19 @@ public function it_has_immutable_setters(): void self::assertSame('NewClickId', $newFbc->getClickId()); } + /** + * @test + */ + public function it_has_immutable_creation_time_setter(): void + { + $fbc = Fbc::fromString('fb.1.1657051589577.ClickId'); + $newFbc = $fbc->withCreationTime(1656874832584); + + self::assertNotSame($fbc, $newFbc); + self::assertSame(1657051589577, $fbc->getCreationTime()); + self::assertSame(1656874832584, $newFbc->getCreationTime()); + } + /** * @test * @@ -64,7 +77,7 @@ public function it_handles_wrong_input(string $input): void /** * @return \Generator */ - public function wrongInputs(): \Generator + public static function wrongInputs(): \Generator { yield ['wrong input']; yield ['afb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA']; diff --git a/tests/ValueObject/FbpTest.php b/tests/ValueObject/FbpTest.php index 32859be..2b0d0d5 100644 --- a/tests/ValueObject/FbpTest.php +++ b/tests/ValueObject/FbpTest.php @@ -57,7 +57,7 @@ public function it_handles_wrong_input(string $input): void /** * @return \Generator */ - public function wrongInputs(): \Generator + public static function wrongInputs(): \Generator { yield ['wrong input']; yield ['fb.1.1656874832584.1088522659a']; From 45b89f241652365e8f23bbdedf222399b148d288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 15 Jun 2026 10:32:34 +0200 Subject: [PATCH 2/8] Fix CI: restore BC for setResponseFactory and PHPStan on lowest deps - Restore Client::setResponseFactory() (removing it broke BC); make the companion getResponseFactory() public so the response factory is used and PHPStan is happy - Type TestLogger::log()'s $context (psr/log 1.x has no value-typed $context, which tripped missingType.iterableValue on the lowest matrix) - Build LiveClientTest::getTestValues() as an explicit array shape via an env() helper so PHPStan 2.1.x can prove the declared return type --- src/Client/Client.php | 17 +++++++++++++++++ tests/Client/ClientTest.php | 1 + tests/Client/LiveClientTest.php | 30 +++++++++++++++--------------- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/Client/Client.php b/src/Client/Client.php index f563e08..068e7fe 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -9,6 +9,7 @@ use Http\Discovery\Psr18ClientDiscovery; use Psr\Http\Client\ClientInterface as HttpClientInterface; use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; @@ -22,6 +23,8 @@ final class Client implements ClientInterface, LoggerAwareInterface private ?RequestFactoryInterface $requestFactory = null; + private ?ResponseFactoryInterface $responseFactory = null; + private ?StreamFactoryInterface $streamFactory = null; private LoggerInterface $logger; @@ -98,6 +101,20 @@ public function setRequestFactory(RequestFactoryInterface $requestFactory): void $this->requestFactory = $requestFactory; } + public function getResponseFactory(): ResponseFactoryInterface + { + if (null === $this->responseFactory) { + $this->responseFactory = Psr17FactoryDiscovery::findResponseFactory(); + } + + return $this->responseFactory; + } + + public function setResponseFactory(ResponseFactoryInterface $responseFactory): void + { + $this->responseFactory = $responseFactory; + } + private function getStreamFactory(): StreamFactoryInterface { if (null === $this->streamFactory) { diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index c6d1649..fe5b993 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -141,6 +141,7 @@ final class TestLogger extends AbstractLogger /** * @param mixed $level * @param string|\Stringable $message + * @param array $context */ public function log($level, $message, array $context = []): void { diff --git a/tests/Client/LiveClientTest.php b/tests/Client/LiveClientTest.php index b3ed5e7..76661cd 100644 --- a/tests/Client/LiveClientTest.php +++ b/tests/Client/LiveClientTest.php @@ -46,23 +46,23 @@ public function it_sends_event(): void */ private function getTestValues(): array { - $envVars = [ - 'pixelId' => 'PIXEL_ID', - 'testEventCode' => 'TEST_EVENT_CODE', - 'accessToken' => 'ACCESS_TOKEN', - 'url' => 'URL', - 'email' => 'EMAIL', + return [ + 'pixelId' => self::env('PIXEL_ID'), + 'testEventCode' => self::env('TEST_EVENT_CODE'), + 'accessToken' => self::env('ACCESS_TOKEN'), + 'url' => self::env('URL'), + 'email' => self::env('EMAIL'), ]; + } - $values = []; - - foreach ($envVars as $variable => $envVar) { - $value = getenv($envVar); - Assert::stringNotEmpty($value, sprintf('%s environment value is not set', $envVar)); - - $values[$variable] = $value; - } + /** + * @return non-empty-string + */ + private static function env(string $name): string + { + $value = getenv($name); + Assert::stringNotEmpty($value, sprintf('%s environment value is not set', $name)); - return $values; + return $value; } } From c34e960f0e644d766fb9b731368adb08b02e581c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 15 Jun 2026 10:51:23 +0200 Subject: [PATCH 3/8] Address review feedback - Client: drop the public getResponseFactory() getter; keep setResponseFactory() as a @deprecated no-op retained only for BC (the client never creates responses, so the response factory was always unused) - Parameters: document why getPayload() iterates the mapping (to keep the precise array return type that FbqGenerator::generateInit() consumes) --- src/Client/Client.php | 15 +++------------ src/Event/Parameters.php | 3 +++ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/Client/Client.php b/src/Client/Client.php index 068e7fe..c296969 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -23,8 +23,6 @@ final class Client implements ClientInterface, LoggerAwareInterface private ?RequestFactoryInterface $requestFactory = null; - private ?ResponseFactoryInterface $responseFactory = null; - private ?StreamFactoryInterface $streamFactory = null; private LoggerInterface $logger; @@ -101,18 +99,11 @@ public function setRequestFactory(RequestFactoryInterface $requestFactory): void $this->requestFactory = $requestFactory; } - public function getResponseFactory(): ResponseFactoryInterface - { - if (null === $this->responseFactory) { - $this->responseFactory = Psr17FactoryDiscovery::findResponseFactory(); - } - - return $this->responseFactory; - } - + /** + * @deprecated the client does not create responses, so the response factory is not used; this method is kept for backwards compatibility only + */ public function setResponseFactory(ResponseFactoryInterface $responseFactory): void { - $this->responseFactory = $responseFactory; } private function getStreamFactory(): StreamFactoryInterface diff --git a/src/Event/Parameters.php b/src/Event/Parameters.php index c3e18e2..7bf1354 100644 --- a/src/Event/Parameters.php +++ b/src/Event/Parameters.php @@ -22,6 +22,9 @@ abstract class Parameters */ public function getPayload(string $context = self::PAYLOAD_CONTEXT_SERVER): array { + // The mapping keys are field names (strings), so iterating here lets the return type stay the precise + // array that consumers like FbqGenerator::generateInit() rely on. The recursive normalize() + // below works on values of unknown key type, hence it can only yield array. $payload = []; foreach ($this->getMapping($context) as $field => $value) { $payload[$field] = $value instanceof self ? $value->getPayload() : self::normalize($value, $field); From 2b149d03451cdef928d9e2c08ca19b233e7cd97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 15 Jun 2026 10:57:03 +0200 Subject: [PATCH 4/8] Drop phpVersion from PHPStan config The static-analysis CI matrix runs on PHP 8.1-8.4, so each version is analysed natively; the explicit phpVersion pin is redundant. --- phpstan.dist.neon | 1 - 1 file changed, 1 deletion(-) diff --git a/phpstan.dist.neon b/phpstan.dist.neon index c09b691..ca7eec6 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,6 +1,5 @@ parameters: level: max - phpVersion: 80100 treatPhpDocTypesAsCertain: false paths: - src From cb2fc4a870332cd0a1525a7c1d0eacbe2c31de72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 15 Jun 2026 11:03:25 +0200 Subject: [PATCH 5/8] Add characterization test for the full getPayload pipeline Pins the exact getPayload() output for a fully-populated object graph (nested Parameters, lists, scalars, normalization, hashing, value objects, custom properties, empty-value filtering). Verified to pass against both the pre-refactor (master) and refactored getPayload() implementations, proving the refactor is behaviour-preserving. --- tests/Event/EventTest.php | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/Event/EventTest.php b/tests/Event/EventTest.php index 6cd3204..a267814 100644 --- a/tests/Event/EventTest.php +++ b/tests/Event/EventTest.php @@ -6,9 +6,82 @@ use PHPUnit\Framework\TestCase; use Setono\MetaConversionsApi\Pixel\Pixel; +use Setono\MetaConversionsApi\ValueObject\Fbc; +use Setono\MetaConversionsApi\ValueObject\Fbp; final class EventTest extends TestCase { + /** + * Characterization test that pins the exact output of the whole serialization pipeline + * (nested Parameters objects, lists, scalars, normalization, hashing, value objects, + * custom properties and empty-value filtering) so any change to getPayload() is caught. + * + * @test + */ + public function it_generates_the_full_payload(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventTime = 1658743659; + $event->eventId = 'event_id'; + $event->eventSourceUrl = 'https://example.com/checkout'; + $event->optOut = false; + $event->dataProcessingOptions = ['LDU']; + $event->dataProcessingOptionsCountry = 1; + $event->dataProcessingOptionsState = 1000; + + $event->userData->email[] = 'JohnDoe@Example.com'; + $event->userData->phoneNumber[] = '+1 (555) 123-4567'; + $event->userData->firstName[] = 'John'; + $event->userData->lastName[] = 'Doe'; + $event->userData->city[] = 'Copenhagen'; + $event->userData->country[] = 'DK'; + $event->userData->externalId[] = 'ext-123'; + $event->userData->clientIpAddress = '192.168.0.1'; + $event->userData->clientUserAgent = 'Chrome'; + $event->userData->fbc = Fbc::fromString('fb.1.1657051589577.ClickId'); + $event->userData->fbp = Fbp::fromString('fb.1.1656874832584.1088522659'); + + $event->customData->value = 110.5; + $event->customData->currency = 'DKK'; + $event->customData->contentIds = ['PROD_1']; + $event->customData->contents[] = new Content('PROD_1', 2, 55.25, 'home_delivery'); + $event->customData->customProperties['my_custom'] = 'x'; + + self::assertEquals([ + 'event_name' => 'Purchase', + 'event_time' => 1658743659, + 'user_data' => [ + 'em' => ['55e79200c1635b37ad31a378c39feb12f120f116625093a19bc32fff15041149'], + 'ph' => ['d6736136ea896c1bfdc553e0e86e702c70d060d805696ca3e4e9e0961353860a'], + 'fn' => ['96d9632f363564cc3032521409cf22a852f2032eec099ed5967c0d000cec607a'], + 'ln' => ['799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f'], + 'ct' => ['842de7b239f0d6ab17dc08d3fcfe68c090a9af0eb5285cfbc5433b3304c5ebee'], + 'country' => ['867b4bf4357a7c0e415ffd537f61ea8785dd47113104000b534a130c98a42ce8'], + 'external_id' => ['ext-123'], + 'client_ip_address' => '192.168.0.1', + 'client_user_agent' => 'Chrome', + 'fbc' => 'fb.1.1657051589577.ClickId', + 'fbp' => 'fb.1.1656874832584.1088522659', + ], + 'custom_data' => [ + 'my_custom' => 'x', + 'content_ids' => ['PROD_1'], + 'contents' => [ + ['id' => 'PROD_1', 'quantity' => 2, 'item_price' => 55.25, 'delivery_category' => 'home_delivery'], + ], + 'currency' => 'dkk', + 'value' => 110.5, + ], + 'event_source_url' => 'https://example.com/checkout', + 'opt_out' => false, + 'event_id' => 'event_id', + 'action_source' => 'website', + 'data_processing_options' => ['LDU'], + 'data_processing_options_country' => 1, + 'data_processing_options_state' => 1000, + ], $event->getPayload()); + } + /** * @test */ From 117024fc750c4f46c8680d5a7f5681dee8a13b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 15 Jun 2026 11:16:58 +0200 Subject: [PATCH 6/8] Capture Meta's extended error fields in ErrorResponse Resolves the TODO in ErrorResponse: besides message/type/code/fbtrace_id, Meta may also return error_subcode, is_transient and the user-facing error_user_title / error_user_msg fields. These optional fields are now parsed when present, and ClientException surfaces the subcode and the user-facing explanation in its message. --- src/Client/ErrorResponse.php | 41 +++++++++++++++++++++++++++--- src/Exception/ClientException.php | 19 +++++++++++--- tests/Client/ErrorResponseTest.php | 24 +++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/Client/ErrorResponse.php b/src/Client/ErrorResponse.php index 06c98f1..186f424 100644 --- a/src/Client/ErrorResponse.php +++ b/src/Client/ErrorResponse.php @@ -8,9 +8,18 @@ use Webmozart\Assert\Assert; /** - * todo also handle a JSON response like this: + * Represents the error envelope Meta/Facebook returns when a request fails. * - * {"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_subcode":2804050,"is_transient":false,"error_user_title":"Du har ikke tilf\u00f8jet tilstr\u00e6kkelige data om kundeoplysningsparametre for denne h\u00e6ndelse","error_user_msg":"Denne h\u00e6ndelse har ingen kundeoplysningsparametre, eller den har en kombination af kundeoplysningsparametre, der er s\u00e5 bred, at det er usandsynligt, at det vil v\u00e6re effektivt til matchning. Du kan l\u00f8se dette ved at g\u00e5 til de anbefalede fremgangsm\u00e5der for parametre p\u00e5 developers.facebook.com\/docs\/marketing-api\/conversions-api\/best-practices\/#req-rec-params","fbtrace_id":"Asu2mk752HZ0oT07IksFAwN"}} + * The minimal shape is: + * + * {"error":{"message":"..","type":"..","code":100,"fbtrace_id":".."}} + * + * but Meta may also send the optional error_subcode, is_transient and the user facing + * error_user_title / error_user_msg fields, e.g.: + * + * {"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_subcode":2804050,"is_transient":false,"error_user_title":"..","error_user_msg":"..","fbtrace_id":".."}} + * + * Those optional fields are captured when present. * * @internal */ @@ -29,6 +38,14 @@ final class ErrorResponse public string $traceId; + public ?int $subcode = null; + + public ?bool $transient = null; + + public ?string $userTitle = null; + + public ?string $userMessage = null; + private function __construct(string $json, string $message, string $type, int $code, string $traceId) { $this->json = $json; @@ -66,10 +83,28 @@ public static function fromJson(string $json): self Assert::string($type); Assert::integer($code); Assert::string($traceId); + + $subcode = $error['error_subcode'] ?? null; + Assert::nullOrInteger($subcode); + + $transient = $error['is_transient'] ?? null; + Assert::nullOrBoolean($transient); + + $userTitle = $error['error_user_title'] ?? null; + Assert::nullOrString($userTitle); + + $userMessage = $error['error_user_msg'] ?? null; + Assert::nullOrString($userMessage); } catch (\InvalidArgumentException $e) { throw ClientException::invalidResponseFormat($json); } - return new self($json, $message, $type, $code, $traceId); + $self = new self($json, $message, $type, $code, $traceId); + $self->subcode = $subcode; + $self->transient = $transient; + $self->userTitle = $userTitle; + $self->userMessage = $userMessage; + + return $self; } } diff --git a/src/Exception/ClientException.php b/src/Exception/ClientException.php index 9911b03..cced96e 100644 --- a/src/Exception/ClientException.php +++ b/src/Exception/ClientException.php @@ -32,14 +32,25 @@ public static function invalidResponseFormat(string $json): self public static function fromErrorResponse(ErrorResponse $errorResponse): self { $message = sprintf( - "An error occurred sending an event to Meta/Facebook: %s (code: %d, type: %s, trace id: %s)\n\nRaw JSON response:\n\n%s", + 'An error occurred sending an event to Meta/Facebook: %s (code: %d', $errorResponse->message, $errorResponse->code, - $errorResponse->type, - $errorResponse->traceId, - $errorResponse->json, ); + if (null !== $errorResponse->subcode) { + $message .= sprintf(', subcode: %d', $errorResponse->subcode); + } + + $message .= sprintf(', type: %s, trace id: %s)', $errorResponse->type, $errorResponse->traceId); + + // The user_* fields, when present, hold a human readable explanation aimed at the end user + $userMessage = trim(sprintf('%s %s', $errorResponse->userTitle ?? '', $errorResponse->userMessage ?? '')); + if ('' !== $userMessage) { + $message .= sprintf("\n\n%s", $userMessage); + } + + $message .= sprintf("\n\nRaw JSON response:\n\n%s", $errorResponse->json); + return new self($message); } } diff --git a/tests/Client/ErrorResponseTest.php b/tests/Client/ErrorResponseTest.php index 1f76d17..12463c2 100644 --- a/tests/Client/ErrorResponseTest.php +++ b/tests/Client/ErrorResponseTest.php @@ -26,6 +26,30 @@ public function it_parses_a_valid_error_response(): void self::assertSame('OAuthException', $errorResponse->type); self::assertSame(100, $errorResponse->code); self::assertSame('trace123', $errorResponse->traceId); + + // the optional fields are null when not present + self::assertNull($errorResponse->subcode); + self::assertNull($errorResponse->transient); + self::assertNull($errorResponse->userTitle); + self::assertNull($errorResponse->userMessage); + } + + /** + * @test + */ + public function it_captures_the_optional_fields_when_present(): void + { + $json = '{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_subcode":2804050,"is_transient":false,"error_user_title":"Customer information parameters","error_user_msg":"This event has insufficient customer information.","fbtrace_id":"trace123"}}'; + + $errorResponse = ErrorResponse::fromJson($json); + + self::assertSame('Invalid parameter', $errorResponse->message); + self::assertSame(100, $errorResponse->code); + self::assertSame(2804050, $errorResponse->subcode); + self::assertFalse($errorResponse->transient); + self::assertSame('Customer information parameters', $errorResponse->userTitle); + self::assertSame('This event has insufficient customer information.', $errorResponse->userMessage); + self::assertSame('trace123', $errorResponse->traceId); } /** From 613bfff114909d4b9a49ffcf3316bbeb50548033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 15 Jun 2026 11:16:58 +0200 Subject: [PATCH 7/8] Make the README developer friendly Rewrite with runnable examples covering the common cases (server events, multiple pixels, test events, error handling, browser-side fbq() generation with deduplication, custom events, fbc/fbp, custom HTTP client, logging, extending events) and document the automatic normalization/hashing behaviour. Also fixes the missing Client import in the original usage example. --- README.md | 217 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 208 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ca6023e..88bb458 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PHP library with basic objects and more for working with Facebook/Metas Conversions API +# Meta (Facebook) Conversions API PHP SDK [![Latest Version][ico-version]][link-packagist] [![Software License][ico-license]](LICENSE) @@ -6,34 +6,233 @@ [![Code Coverage][ico-code-coverage]][link-code-coverage] [![Mutation testing][ico-infection]][link-infection] +A small, typed PHP library for sending server-side events to Meta's (Facebook's) +[Conversions API](https://developers.facebook.com/docs/marketing-api/conversions-api), and for generating the +matching browser-side `fbq()` snippets. + +It gives you plain, well-typed objects (`Event`, `User`, `Custom`, …) and takes care of the fiddly parts for you: + +- **Automatic normalization & hashing** of customer information — you pass raw emails, phone numbers, names, etc. and + the SDK normalizes and SHA-256 hashes them the way Meta requires. Never hash this data yourself. +- **Server + browser deduplication** — every event gets an `eventId` you can reuse on both sides so Meta counts it once. +- **Bring your own HTTP client** — built on PSR-18/PSR-17 with auto-discovery, so it works with any compliant client. + +## Requirements + +- PHP 8.1+ +- A [PSR-18](https://www.php-fig.org/psr/psr-18/) HTTP client and [PSR-17](https://www.php-fig.org/psr/psr-17/) factories + (see [Installation](#installation)) + ## Installation -The easiest way to install this library is by installing the library along with its HTTP client dependencies: +The SDK talks to the API through a PSR-18 client and PSR-17 factories, which it discovers automatically. The simplest +way is to install it together with an implementation: ```bash composer require setono/meta-conversions-api-php-sdk kriswallsmith/buzz nyholm/psr7 ``` -If you want to use your own HTTP client, just do `composer require setono/meta-conversions-api-php-sdk` and then -remember to set the HTTP client and factories when instantiating the `Setono\MetaConversionsApi\Client\Client` +`symfony/http-client` works just as well if you prefer it: -## Usage +```bash +composer require setono/meta-conversions-api-php-sdk symfony/http-client nyholm/psr7 +``` + +If your project already ships a PSR-18 client and PSR-17 factories you only need the SDK itself +(`composer require setono/meta-conversions-api-php-sdk`); see [Using your own HTTP client](#using-your-own-http-client). + +## Quick start ```php +use Setono\MetaConversionsApi\Client\Client; use Setono\MetaConversionsApi\Event\Event; use Setono\MetaConversionsApi\Pixel\Pixel; $event = new Event(Event::EVENT_VIEW_CONTENT); $event->eventSourceUrl = 'https://example.com/products/blue-jeans'; -$event->userData->clientUserAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36'; -$event->userData->email[] = 'johndoe@example.com'; -$event->pixels[] = new Pixel('INSERT YOUR PIXEL ID', 'INSERT YOUR ACCESS TOKEN'); -// $event->testEventCode = 'test event code'; // uncomment this if you want to send a test event +$event->userData->clientUserAgent = $_SERVER['HTTP_USER_AGENT']; +$event->userData->clientIpAddress = $_SERVER['REMOTE_ADDR']; +$event->userData->email[] = 'johndoe@example.com'; // hashed for you before sending + +// A pixel carries the id and the access token used to authenticate the request +$event->pixels[] = new Pixel('YOUR_PIXEL_ID', 'YOUR_ACCESS_TOKEN'); $client = new Client(); $client->sendEvent($event); ``` +An `Event` is created with a random `eventId` and the current `eventTime` already set, and defaults to the `website` +action source. Pass a different source as the second constructor argument if needed (e.g. +`new Event(Event::EVENT_PURCHASE, Event::ACTION_SOURCE_PHYSICAL_STORE)`). + +## Sending a richer event + +`Event::$customData` holds the event-specific data (value, currency, contents, …) and `Event::$userData` holds the +customer-matching data: + +```php +use Setono\MetaConversionsApi\Client\Client; +use Setono\MetaConversionsApi\Event\Content; +use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Pixel\Pixel; + +$event = new Event(Event::EVENT_PURCHASE); +$event->eventSourceUrl = 'https://example.com/checkout/complete'; + +// Customer information — pass raw values, the SDK normalizes and hashes them +$event->userData->email[] = 'johndoe@example.com'; +$event->userData->phoneNumber[] = '+1 (555) 123-4567'; +$event->userData->firstName[] = 'John'; +$event->userData->lastName[] = 'Doe'; +$event->userData->clientUserAgent = $_SERVER['HTTP_USER_AGENT']; +$event->userData->clientIpAddress = $_SERVER['REMOTE_ADDR']; + +// Event data +$event->customData->currency = 'USD'; +$event->customData->value = 142.52; +$event->customData->contents[] = new Content('SKU-1', 1, 99.99); +$event->customData->contents[] = new Content('SKU-2', 1, 42.53); + +// Anything not covered by a typed property can go into customProperties +$event->customData->customProperties['membership_level'] = 'gold'; + +$event->pixels[] = new Pixel('YOUR_PIXEL_ID', 'YOUR_ACCESS_TOKEN'); + +(new Client())->sendEvent($event); +``` + +### Multiple pixels + +Add more than one `Pixel` and the event is sent to each of them (every pixel carries its own access token): + +```php +$event->pixels[] = new Pixel('PIXEL_ID_1', 'ACCESS_TOKEN_1'); +$event->pixels[] = new Pixel('PIXEL_ID_2', 'ACCESS_TOKEN_2'); +``` + +### Test events + +While integrating, set a test event code so the event shows up in the *Test events* tool in Events Manager instead of +counting as real traffic: + +```php +$event->testEventCode = 'TEST12345'; +``` + +### Error handling + +`sendEvent()` throws a `ClientException` if Meta returns a non-2xx response. The message contains Meta's error message, +code, trace id and the raw response (including the user-facing explanation when Meta provides one): + +```php +use Setono\MetaConversionsApi\Exception\ClientException; + +try { + $client->sendEvent($event); +} catch (ClientException $e) { + $logger->error('Could not send event to Meta', ['exception' => $e]); +} +``` + +## Browser-side tracking with deduplication + +To get the best match quality Meta recommends sending events both server-side (this SDK) *and* from the browser, using +the same `eventId` so they are deduplicated. `FbqGenerator` produces the matching JavaScript: + +```php +use Setono\MetaConversionsApi\Event\Parameters; +use Setono\MetaConversionsApi\Generator\FbqGenerator; + +$generator = new FbqGenerator(); + +// In your : initialise the pixel(s) and send a PageView +echo $generator->generateInit( + $event->pixels, + $event->userData->getPayload(Parameters::PAYLOAD_CONTEXT_BROWSER), +); + +// Where the conversion happens: fire the same event in the browser. +// Because it reuses $event->eventId, Meta counts the server + browser event once. +echo $generator->generateTrack($event); +``` + +Both methods wrap the output in a `