setono/meta-conversions-api-bundle
Symfony bundle for integrating the Meta Conversions API, enabling reliable server-side conversion tracking. Configure your pixel credentials and send events directly from your application to complement browser tracking with more resilient, privacy-conscious server events.
Install and wire it up
composer require setono/meta-conversions-api-bundle
If you're not on Symfony Flex, add the bundle to config/bundles.php manually. Then create a minimal config file. Exact keys vary by version, so confirm them first:
bin/console debug:config setono_meta_conversions_api
bin/console config:dump-reference setono_meta_conversions_api
A typical configuration looks like this:
# config/packages/setono_meta_conversions_api.yaml
setono_meta_conversions_api:
pixel_id: '%env(META_PIXEL_ID)%'
access_token: '%env(META_ACCESS_TOKEN)%'
test_event_code: '%env(default::META_TEST_EVENT_CODE)%'
Keep the token in .env.local / a secret store, never in the committed YAML.
Where to look first
vendor/setono/meta-conversions-api-bundle/README.md and src/DependencyInjection/Configuration.php — the README is the source of truth for config keys; the Configuration class tells you what's actually supported.src/Resources/config/services.xml — find the client service ID (it will be a Setono\MetaConversionsApiBundle\...\ClientInterface or similar). Confirm with:bin/console debug:container --show-private | grep -i meta
setono/meta-conversions-api library in vendor/ — that's where the Event, UserData, CustomData, and content classes live. The bundle is mostly DI glue around it; most of your day-to-day work is with those value objects.First use case: a server-side Purchase
The canonical win is sending a Purchase event from your checkout success controller (or an order subscriber) so it deduplicates against the browser pixel. Build the event once, give it an event_id, and pass the same event_id to the browser-side fbq('track', 'Purchase', {...}, {eventID: '...'}) call.
public function __construct(private ClientInterface $metaClient) {}
public function onOrderPaid(Order $order): void
{
$event = new Event(
eventName: 'Purchase',
eventTime: $order->getPaidAt()->getTimestamp(),
eventId: 'order-'.$order->getId(), // must match browser pixel
actionSource: 'website',
userData: (new UserData())
->setEmails([$order->getCustomerEmail()])
->setClientIpAddress($ip)
->setClientUserAgent($ua)
->setFbc($fbcCookie)
->setFbp($fbpCookie),
customData: (new CustomData())
->setCurrency('USD')
->setValue(99.00),
);
$this->metaClient->sendEvents([$event]);
}
Hook into domain events, not controllers. A Doctrine listener on order state transitions or a Symfony event subscriber on your OrderPaidEvent keeps the Meta call out of HTTP code and makes it reusable for refunds (Refund), subscription renewals, and admin-created orders. Avoid postFlush hooks that make network calls inside the DB transaction.
Send asynchronously with Messenger. Checkout latency shouldn't depend on Meta's API. Serialize the event DTO into a message and dispatch it to a queue:
$this->bus->dispatch(new SendMetaEventMessage($event));
If the library's Event object isn't cleanly serializable, keep a small DTO with scalars (event name, order id, value, currency, hashed PII, fbp/fbc) and rebuild the Event in the handler. This also gives you retries and a dead-letter queue for free.
Decorator around the client. Wrap the bundle client in your own service that implements the same interface, and decorate it:
App\Meta\LoggingClient:
decorates: Setono\MetaConversionsApiBundle\...\ClientInterface
Use this for: no-op in dev/test, tagging the test_event_code in staging, logging request/response payloads, and swallowing failures so a marketing API never breaks checkout.
Always send deduplication identifiers. Meta deduplicates browser + server events by (event_name, event_id) within ~48 hours. Generate an event_id at the point where the browser pixel fires (order id, cart token, UUID in a data attribute) and reuse it server-side. If you can't share it, at least send fbp/fbc and consistent event_time.
Capture request context at the edge. fbp, fbc, client IP, and user agent only exist on the inbound HTTP request. Read them in the controller/subscriber, put them in the message payload, and send them later. This is the single most common source of "event received, no match quality" complaints.
Consent gating belongs to you. The bundle won't know about your CMP. Gate on your consent service before dispatching, and decide policy for action_source (website vs system_generated for back-office or offline orders).
Multi-dataset setups. If you run several pixels/datasets, don't hack the single global config. Instantiate additional clients from the underlying library directly (or a small factory service) and register them by name. The bundle's config is designed for the common single-dataset case.
Testing. In test env, decorate the client with one that records events in memory, and assert on it from a functional test after hitting checkout. Keep test_event_code set in staging so events land in Events Manager's Test Events tab instead of polluting production reporting.
200 OK with events_received: 0 or a messages array containing errors (bad token, expired event_time, invalid currency, unknown field). Log the decoded body, and assert on events_received > 0 in your decorator at least in staging.UserData for you — read the source, don't assume. Double-hashing produces silently unmatched events, which is worse than an obvious error.action_source: system_generated.APP_DEBUG=0 in prod, and use a system-user token with the minimum dataset scope rather than a personal token.$requestStack->getCurrentRequest() is null. Capture context in the producer. This is the #1 bug in every CAPI integration.symfony HttpClient/PSR-18 client. The bundle usually exposes an overridable HTTP client service — decorate or alias it rather than fighting SSL settings in YAML.debug:config for resolved config → debug:container for the real client ID → debug:event-dispatcher to confirm your subscriber runs → log the raw response body → check Events Manager's Test Events tab and the "Diagnostics" panel, which reports deduplication and match-quality issues.composer.json, read the bundle source end-to-end (~a few files, it's glue code), and be ready to vendor it or bypass it. The escape hatch is easy: require setono/meta-conversions-api, build the client yourself from config, and drop the bundle — the bundle only saves you a dozen lines of DI.http_client options (retry + timeout). Anything else — new event types, new user-data fields — is a change in the underlying library, so contribute there rather than forking the bundle.How can I help you explore Laravel packages today?