From fbfe4818b81f21582189d9da4d62394ce4f52c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 21 Sep 2026 09:52:05 +0200 Subject: [PATCH 1/2] Let the client send a PreparedEvent, not only an Event A consumer that hashes the personal data at capture time and sends later, e.g. through a queue, could neither queue the Event (User holds the raw PII until the payload is built) nor hand the finished payload back to the client, which only accepted an Event. Event::prepare() now returns a PreparedEvent: the payload, already normalized and hashed, together with the delivery information (event name and id, the pixels and the test event code). It is made of scalars, arrays and Pixel objects only, so it serializes without any tricks. ClientInterface gains sendPreparedEvent() to send it, and Client::sendEvent() is a one-line delegation to it. The pixels are cloned, so the prepared event is a snapshot, and PreparedEvent::withoutAccessTokens()/withAccessTokens() keep the access tokens out of the queue and restore them by pixel id before sending. "Payload" keeps its 1.x meaning, the array returned by getPayload(); the new object is named for what it is, the payload plus where to send it. The added interface method is the only BC break. See UPGRADE-2.0.md. Closes #15 --- CLAUDE.md | 2 +- README.md | 21 +++++ UPGRADE-2.0.md | 13 ++++ src/Client/Client.php | 16 ++-- src/Client/ClientInterface.php | 9 +++ src/Event/Event.php | 16 ++++ src/Event/PreparedEvent.php | 66 ++++++++++++++++ tests/Client/ClientTest.php | 111 ++++++++++++++++++++++++++ tests/Event/EventTest.php | 50 ++++++++++++ tests/Event/PreparedEventTest.php | 124 ++++++++++++++++++++++++++++++ 10 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 UPGRADE-2.0.md create mode 100644 src/Event/PreparedEvent.php create mode 100644 tests/Event/PreparedEventTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 7d9a5c7..2aa6871 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ So to add a field: add the public property, map it in `getMapping()`, and regist **`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`. +**`Client` (`src/Client/Client.php`)** — `sendEvent()` delegates to `sendPreparedEvent($event->prepare())`. `Event::prepare()` returns a `PreparedEvent` (`src/Event/PreparedEvent.php`): the `getPayload()` array plus the delivery information (event name and id, pixels, test event code), made of scalars/arrays/`Pixel` only so consumers can hash at capture time and queue it. The pixels are cloned so it is a snapshot, and `withoutAccessTokens()`/`withAccessTokens()` (immutable) keep the tokens out of the queue and restore them by pixel id before sending. `sendPreparedEvent()` POSTs the payload (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per 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`. diff --git a/README.md b/README.md index ef88dd5..ae9f080 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,27 @@ try { } ``` +## Sending events later, e.g. through a queue + +`User` holds the raw email addresses, phone numbers and names until the payload is built, so an `Event` should not be +queued as is. `Event::prepare()` returns a `PreparedEvent` instead: the payload, already normalized and hashed, together +with the pixels and the test event code. It is made of scalars, arrays and `Pixel` objects only, so it serializes with +the PHP serializer or the Symfony serializer without any tricks. Hash at capture time, send later: + +```php +// at capture time +$queue->push($event->prepare()->withoutAccessTokens()); + +// at send time +$client->sendPreparedEvent($preparedEvent->withAccessTokens([ + 'your_pixel_id' => 'your_access_token', +])); +``` + +`withoutAccessTokens()` keeps the access tokens out of the queue and of any failure storage behind it, and +`withAccessTokens()` takes the tokens indexed by pixel id and leaves pixels that are not in the list as they are. Both +return a new instance. If your queue is trusted with the access tokens, you can skip both calls. + ## 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 diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 0000000..1f07217 --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,13 @@ +# Upgrade from 1.x to 2.0 + +## `ClientInterface` has a second method, `sendPreparedEvent()` + +If you implement `ClientInterface` yourself, add: + +```php +public function sendPreparedEvent(PreparedEvent $preparedEvent): void; +``` + +It sends an event that was prepared earlier with `Event::prepare()`, i.e. with the payload already normalized and +hashed, which makes it safe to store or queue. `Client` implements it. `sendEvent()` is unchanged and now delegates to +`sendPreparedEvent($event->prepare())`. diff --git a/src/Client/Client.php b/src/Client/Client.php index f563e08..07fcef2 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -14,6 +14,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\PreparedEvent; use Setono\MetaConversionsApi\Exception\ClientException; final class Client implements ClientInterface, LoggerAwareInterface @@ -33,7 +34,12 @@ public function __construct() public function sendEvent(Event $event): void { - if (!$event->hasPixels()) { + $this->sendPreparedEvent($event->prepare()); + } + + public function sendPreparedEvent(PreparedEvent $preparedEvent): void + { + if ([] === $preparedEvent->pixels) { $this->logger->error('You are trying to send events to Meta/Facebook, but you haven\'n associated any pixels with your event. This is most likely an error.'); return; @@ -42,16 +48,16 @@ public function sendEvent(Event $event): void $httpClient = $this->getHttpClient(); $requestFactory = $this->getRequestFactory(); - $data = json_encode([$event->getPayload()], \JSON_THROW_ON_ERROR); + $data = json_encode([$preparedEvent->payload], \JSON_THROW_ON_ERROR); - foreach ($event->pixels as $pixel) { + foreach ($preparedEvent->pixels as $pixel) { $body = [ 'access_token' => $pixel->accessToken, 'data' => $data, ]; - if (null !== $event->testEventCode) { - $body['test_event_code'] = $event->testEventCode; + if (null !== $preparedEvent->testEventCode) { + $body['test_event_code'] = $preparedEvent->testEventCode; } $request = $requestFactory->createRequest( diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php index 136a08b..d8ba241 100644 --- a/src/Client/ClientInterface.php +++ b/src/Client/ClientInterface.php @@ -5,6 +5,7 @@ namespace Setono\MetaConversionsApi\Client; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\PreparedEvent; use Setono\MetaConversionsApi\Exception\ClientException; /** @@ -16,4 +17,12 @@ interface ClientInterface * @throws ClientException if the request failed in any way */ public function sendEvent(Event $event): void; + + /** + * Sends an event that was prepared earlier with Event::prepare(). Use this when the personal data is hashed + * at capture time and the event is sent later, for instance through a queue + * + * @throws ClientException if the request failed in any way + */ + public function sendPreparedEvent(PreparedEvent $preparedEvent): void; } diff --git a/src/Event/Event.php b/src/Event/Event.php index ff1797a..c32a5a9 100644 --- a/src/Event/Event.php +++ b/src/Event/Event.php @@ -138,6 +138,22 @@ public function hasPixels(): bool return [] !== $this->pixels; } + /** + * Returns this event in its ready-to-send form: the payload is built, i.e. normalized and hashed, so the result + * holds no raw personal data and is safe to store or queue. Send it later with ClientInterface::sendPreparedEvent() + */ + public function prepare(): PreparedEvent + { + return new PreparedEvent( + $this->eventName, + $this->eventId, + $this->getPayload(), + // cloned so the prepared event is a snapshot: changing a pixel on either side must not change the other + array_map(static fn (Pixel $pixel): Pixel => clone $pixel, $this->pixels), + $this->testEventCode, + ); + } + /** * @return list */ diff --git a/src/Event/PreparedEvent.php b/src/Event/PreparedEvent.php new file mode 100644 index 0000000..366a491 --- /dev/null +++ b/src/Event/PreparedEvent.php @@ -0,0 +1,66 @@ + $payload the result of Event::getPayload() + * @param list $pixels the pixels the event should be sent to + */ + public function __construct( + public readonly string $eventName, + public readonly string $eventId, + public readonly array $payload, + public readonly array $pixels, + public readonly ?string $testEventCode = null, + ) { + } + + /** + * Returns a copy where the pixels carry no access tokens + */ + public function withoutAccessTokens(): self + { + return new self( + $this->eventName, + $this->eventId, + $this->payload, + array_map(static fn (Pixel $pixel): Pixel => new Pixel($pixel->id), $this->pixels), + $this->testEventCode, + ); + } + + /** + * Returns a copy where the pixels carry the given access tokens. Pixels that are not in the list are left as they are + * + * @param array $accessTokens the access tokens indexed by pixel id + */ + public function withAccessTokens(array $accessTokens): self + { + return new self( + $this->eventName, + $this->eventId, + $this->payload, + array_map( + static fn (Pixel $pixel): Pixel => new Pixel($pixel->id, $accessTokens[$pixel->id] ?? $pixel->accessToken), + $this->pixels, + ), + $this->testEventCode, + ); + } +} diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index d69d353..aeea6f6 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -18,6 +18,7 @@ use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\PreparedEvent; use Setono\MetaConversionsApi\Exception\ClientException; use Setono\MetaConversionsApi\Pixel\Pixel; use Setono\MetaConversionsApi\TestLogger; @@ -127,6 +128,116 @@ public function it_discovers_an_http_client_when_none_is_injected(): void self::assertCount(1, $httpClient->requests); } + /** + * @test + */ + public function it_sends_prepared_event(): void + { + $httpClient = new TestHttpClient(); + + $client = new Client(); + $client->setHttpClient($httpClient); + + $preparedEvent = new PreparedEvent( + Event::EVENT_PURCHASE, + 'event_id', + ['event_name' => 'Purchase', 'event_time' => 1658743659123, 'event_id' => 'event_id', 'action_source' => 'website'], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2', 'token_2')], + 'TEST123', + ); + $client->sendPreparedEvent($preparedEvent); + + self::assertCount(2, $httpClient->requests); + + [$first, $second] = $httpClient->requests; + self::assertSame('POST', $first->getMethod()); + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_1/events', ApiConfig::APIVersion), (string) $first->getUri()); + self::assertSame( + 'access_token=token_1&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&test_event_code=TEST123', + (string) $first->getBody(), + ); + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_2/events', ApiConfig::APIVersion), (string) $second->getUri()); + self::assertStringContainsString('access_token=token_2', (string) $second->getBody()); + } + + /** + * @test + */ + public function it_sends_the_same_request_for_an_event_and_its_prepared_event(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 1658743659123; + $event->testEventCode = 'TEST123'; + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + $event->userData->email[] = 'johndoe@example.com'; + + $eventHttpClient = new TestHttpClient(); + $eventClient = new Client(); + $eventClient->setHttpClient($eventHttpClient); + $eventClient->sendEvent($event); + + $preparedEventHttpClient = new TestHttpClient(); + $preparedEventClient = new Client(); + $preparedEventClient->setHttpClient($preparedEventHttpClient); + $preparedEventClient->sendPreparedEvent($event->prepare()); + + self::assertCount(1, $eventHttpClient->requests); + self::assertCount(1, $preparedEventHttpClient->requests); + self::assertSame((string) $eventHttpClient->requests[0]->getUri(), (string) $preparedEventHttpClient->requests[0]->getUri()); + self::assertSame((string) $eventHttpClient->requests[0]->getBody(), (string) $preparedEventHttpClient->requests[0]->getBody()); + } + + /** + * @test + */ + public function it_sends_a_prepared_event_that_was_queued_without_its_access_tokens(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 1658743659123; + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + $event->userData->email[] = 'johndoe@example.com'; + + $eventHttpClient = new TestHttpClient(); + $eventClient = new Client(); + $eventClient->setHttpClient($eventHttpClient); + $eventClient->sendEvent($event); + + $queued = serialize($event->prepare()->withoutAccessTokens()); + self::assertStringNotContainsString('access_token', $queued); + + $preparedEvent = unserialize($queued); + self::assertInstanceOf(PreparedEvent::class, $preparedEvent); + + $preparedEventHttpClient = new TestHttpClient(); + $preparedEventClient = new Client(); + $preparedEventClient->setHttpClient($preparedEventHttpClient); + $preparedEventClient->sendPreparedEvent($preparedEvent->withAccessTokens(['pixel_id' => 'access_token'])); + + self::assertCount(1, $preparedEventHttpClient->requests); + self::assertSame((string) $eventHttpClient->requests[0]->getUri(), (string) $preparedEventHttpClient->requests[0]->getUri()); + self::assertSame((string) $eventHttpClient->requests[0]->getBody(), (string) $preparedEventHttpClient->requests[0]->getBody()); + } + + /** + * @test + */ + public function it_does_not_send_prepared_event_when_it_has_no_pixels(): void + { + $httpClient = new TestHttpClient(); + $logger = new TestLogger(); + + $client = new Client(); + $client->setHttpClient($httpClient); + $client->setLogger($logger); + + $client->sendPreparedEvent(new PreparedEvent(Event::EVENT_PURCHASE, 'event_id', [], [])); + + self::assertCount(0, $httpClient->requests); + self::assertTrue($logger->hasMessageMatching('#you haven\'n associated any pixels#')); + } + /** * @test */ diff --git a/tests/Event/EventTest.php b/tests/Event/EventTest.php index 27eeb16..32c09cb 100644 --- a/tests/Event/EventTest.php +++ b/tests/Event/EventTest.php @@ -223,4 +223,54 @@ public function it_rejects_an_invalid_action_source(): void $event->getPayload(); } + + /** + * @test + */ + public function it_prepares(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 123; + $event->testEventCode = 'TEST123'; + $event->pixels[] = new Pixel('pixel_1', 'token_1'); + $event->pixels[] = new Pixel('pixel_2'); + $event->userData->email[] = 'johndoe@example.com'; + + $preparedEvent = $event->prepare(); + + self::assertSame(Event::EVENT_PURCHASE, $preparedEvent->eventName); + self::assertSame('event_id', $preparedEvent->eventId); + self::assertSame('TEST123', $preparedEvent->testEventCode); + self::assertEquals($event->pixels, $preparedEvent->pixels); + self::assertSame($event->getPayload(), $preparedEvent->payload); + + // the name and id are duplicated from the payload on purpose + self::assertSame($preparedEvent->eventName, $preparedEvent->payload['event_name']); + self::assertSame($preparedEvent->eventId, $preparedEvent->payload['event_id']); + + // the personal data is hashed, i.e. the prepared event is safe to store + self::assertSame(['em' => ['55e79200c1635b37ad31a378c39feb12f120f116625093a19bc32fff15041149']], $preparedEvent->payload['user_data']); + self::assertStringNotContainsString('johndoe@example.com', serialize($preparedEvent)); + } + + /** + * @test + */ + public function it_prepares_a_snapshot(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + + $preparedEvent = $event->prepare(); + + $preparedPixel = $preparedEvent->pixels[0]; + self::assertNotSame($event->pixels[0], $preparedPixel); + + $preparedPixel->accessToken = 'changed on the prepared event'; + self::assertSame('access_token', $event->pixels[0]->accessToken); + + $event->pixels[0]->accessToken = 'changed on the event'; + self::assertSame('changed on the prepared event', $preparedPixel->accessToken); + } } diff --git a/tests/Event/PreparedEventTest.php b/tests/Event/PreparedEventTest.php new file mode 100644 index 0000000..769a9af --- /dev/null +++ b/tests/Event/PreparedEventTest.php @@ -0,0 +1,124 @@ + 'Purchase', 'event_id' => 'event_id', 'user_data' => ['em' => ['hashed']]], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2')], + 'TEST123', + ); + + $unserialized = unserialize(serialize($preparedEvent)); + + self::assertInstanceOf(PreparedEvent::class, $unserialized); + self::assertNotSame($preparedEvent, $unserialized); + self::assertEquals($preparedEvent, $unserialized); + self::assertSame('Purchase', $unserialized->eventName); + self::assertSame('event_id', $unserialized->eventId); + self::assertSame(['event_name' => 'Purchase', 'event_id' => 'event_id', 'user_data' => ['em' => ['hashed']]], $unserialized->payload); + self::assertSame('TEST123', $unserialized->testEventCode); + self::assertCount(2, $unserialized->pixels); + self::assertSame('pixel_1', $unserialized->pixels[0]->id); + self::assertSame('token_1', $unserialized->pixels[0]->accessToken); + self::assertSame('pixel_2', $unserialized->pixels[1]->id); + self::assertNull($unserialized->pixels[1]->accessToken); + } + + /** + * @test + */ + public function it_has_no_test_event_code_by_default(): void + { + $preparedEvent = new PreparedEvent(Event::EVENT_PURCHASE, 'event_id', [], []); + + self::assertNull($preparedEvent->testEventCode); + self::assertSame([], $preparedEvent->pixels); + } + + /** + * @test + */ + public function it_removes_the_access_tokens(): void + { + $preparedEvent = new PreparedEvent( + Event::EVENT_PURCHASE, + 'event_id', + ['event_name' => 'Purchase'], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2', 'token_2')], + 'TEST123', + ); + + $withoutAccessTokens = $preparedEvent->withoutAccessTokens(); + + self::assertNotSame($preparedEvent, $withoutAccessTokens); + self::assertEquals([new Pixel('pixel_1'), new Pixel('pixel_2')], $withoutAccessTokens->pixels); + self::assertStringNotContainsString('token_1', serialize($withoutAccessTokens)); + + // everything else is carried over + self::assertSame('Purchase', $withoutAccessTokens->eventName); + self::assertSame('event_id', $withoutAccessTokens->eventId); + self::assertSame(['event_name' => 'Purchase'], $withoutAccessTokens->payload); + self::assertSame('TEST123', $withoutAccessTokens->testEventCode); + + // the original is untouched + self::assertSame('token_1', $preparedEvent->pixels[0]->accessToken); + self::assertSame('token_2', $preparedEvent->pixels[1]->accessToken); + } + + /** + * @test + */ + public function it_adds_the_access_tokens_by_pixel_id(): void + { + $preparedEvent = new PreparedEvent( + Event::EVENT_PURCHASE, + 'event_id', + ['event_name' => 'Purchase'], + [new Pixel('1234567890'), new Pixel('pixel_2'), new Pixel('pixel_3', 'existing_token')], + 'TEST123', + ); + + // pixel ids are numeric strings in practice, which PHP turns into integer array keys + $withAccessTokens = $preparedEvent->withAccessTokens(['1234567890' => 'token_1', 'pixel_2' => 'token_2']); + + self::assertNotSame($preparedEvent, $withAccessTokens); + self::assertEquals( + [new Pixel('1234567890', 'token_1'), new Pixel('pixel_2', 'token_2'), new Pixel('pixel_3', 'existing_token')], + $withAccessTokens->pixels, + ); + + // everything else is carried over + self::assertSame('Purchase', $withAccessTokens->eventName); + self::assertSame('event_id', $withAccessTokens->eventId); + self::assertSame(['event_name' => 'Purchase'], $withAccessTokens->payload); + self::assertSame('TEST123', $withAccessTokens->testEventCode); + + // the original is untouched + self::assertNull($preparedEvent->pixels[0]->accessToken); + self::assertNull($preparedEvent->pixels[1]->accessToken); + } + + /** + * @test + */ + public function it_replaces_an_existing_access_token(): void + { + $preparedEvent = new PreparedEvent(Event::EVENT_PURCHASE, 'event_id', [], [new Pixel('pixel_id', 'old_token')]); + + self::assertEquals([new Pixel('pixel_id', 'new_token')], $preparedEvent->withAccessTokens(['pixel_id' => 'new_token'])->pixels); + } +} From 5b6caf2770aa16cb296d16fe5a2815a4c736af5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 21 Sep 2026 10:04:42 +0200 Subject: [PATCH 2/2] Bring CLAUDE.md, the README and UPGRADE-2.0.md up to date for 2.0 --- CLAUDE.md | 12 ++++++++---- README.md | 11 ++++++++++- UPGRADE-2.0.md | 23 ++++++++++++++++++++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2aa6871..84f4af8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,15 @@ 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. +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, comparing against the PR's base branch. + +### Branches + +There is no `master`. **`1.x`** is the default branch and holds the released 1.x line: bug fixes and additive changes only, and the BC check must stay green — this is a public library. **`2.x`** is the next major: BC breaks are allowed there, but every one must be documented in `UPGRADE-2.0.md`. Because the BC check compares against the PR's base, it is expected to be red on `2.x` PRs that break BC; its output should match what `UPGRADE-2.0.md` lists. Always pass `--base` to `gh pr create`. `Closes #123` only auto-closes issues when merged into the default branch, so issues fixed on `2.x` have to be closed by hand. ### 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. +`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. With a filled-in `phpunit.xml`, every full `phpunit` run sends a real test event and Infection replays it for every mutant it covers — move `phpunit.xml` aside before running Infection. ## Architecture @@ -48,12 +52,12 @@ So to add a field: add the public property, map it in `getMapping()`, and regist **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`. +**`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), `Content` (a single item in `Custom::$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()` delegates to `sendPreparedEvent($event->prepare())`. `Event::prepare()` returns a `PreparedEvent` (`src/Event/PreparedEvent.php`): the `getPayload()` array plus the delivery information (event name and id, pixels, test event code), made of scalars/arrays/`Pixel` only so consumers can hash at capture time and queue it. The pixels are cloned so it is a snapshot, and `withoutAccessTokens()`/`withAccessTokens()` (immutable) keep the tokens out of the queue and restore them by pixel id before sending. `sendPreparedEvent()` POSTs the payload (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per 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. +**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. Both accept the optional trailing appendix segment that Meta's parameter builder writes (`getAppendix()`/`withAppendix()`) and write it back unchanged, so a cookie value round-trips byte for byte. 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/README.md b/README.md index ae9f080..c5a781d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ [![Code Coverage][ico-code-coverage]][link-code-coverage] [![Mutation testing][ico-infection]][link-infection] +> [!NOTE] +> This is the documentation for **2.x**, which is in development. The stable release lives on the +> [`1.x` branch](https://github.com/Setono/meta-conversions-api-php-sdk/tree/1.x). If you are upgrading, see +> [UPGRADE-2.0.md](UPGRADE-2.0.md). + 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. @@ -32,6 +37,9 @@ way is to install it together with an implementation: composer require setono/meta-conversions-api-php-sdk kriswallsmith/buzz nyholm/psr7 ``` +2.0 is in pre-release. Until it is stable, ask for it explicitly, e.g. +`composer require setono/meta-conversions-api-php-sdk:^2.0@alpha`. + `symfony/http-client` works just as well if you prefer it: ```bash @@ -121,7 +129,7 @@ $event->testEventCode = 'TEST12345'; ### Error handling -`sendEvent()` throws a `ClientException` if Meta returns a non-2xx response. The message contains Meta's error message, +`sendEvent()` and `sendPreparedEvent()` throw 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 @@ -146,6 +154,7 @@ the PHP serializer or the Symfony serializer without any tricks. Hash at capture $queue->push($event->prepare()->withoutAccessTokens()); // at send time +$preparedEvent = $queue->pop(); $client->sendPreparedEvent($preparedEvent->withAccessTokens([ 'your_pixel_id' => 'your_access_token', ])); diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 1f07217..bc0af84 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -1,13 +1,30 @@ # Upgrade from 1.x to 2.0 +The requirements are unchanged: PHP 8.1+ and the same dependencies as 1.x. + ## `ClientInterface` has a second method, `sendPreparedEvent()` -If you implement `ClientInterface` yourself, add: +This is the only backwards-compatibility break, and it only affects you if you implement `ClientInterface` yourself, +for instance in a decorator or a test double. Add: ```php +use Setono\MetaConversionsApi\Event\PreparedEvent; + public function sendPreparedEvent(PreparedEvent $preparedEvent): void; ``` It sends an event that was prepared earlier with `Event::prepare()`, i.e. with the payload already normalized and -hashed, which makes it safe to store or queue. `Client` implements it. `sendEvent()` is unchanged and now delegates to -`sendPreparedEvent($event->prepare())`. +hashed. `Client` implements it, and a decorator can simply forward the call. + +## Behaviour change in `Client::sendEvent()` + +`sendEvent()` now delegates to `sendPreparedEvent($event->prepare())`, so the payload is built before the client checks +whether the event has any pixels. The request that is sent is byte for byte the same as in 1.x. There is one observable +difference: an event without pixels whose data is invalid, an unknown `action_source` for instance, now throws when the +payload is built. In 1.x the client logged the missing pixels and returned without ever building the payload. + +## New in 2.0 + +Nothing you have to change, but worth knowing about: `Event::prepare()` returns a `PreparedEvent` that holds no raw +personal data and can be stored or queued, and `PreparedEvent::withoutAccessTokens()` / `withAccessTokens()` keep the +access tokens out of that storage. See "Sending events later, e.g. through a queue" in the README.