Work with the Meta / Facebook Conversions API in your Symfony application. Under the hood this bundle integrates the Meta Conversions API PHP SDK library.
- PHP 8.1+
- Symfony 6.4 or 7.4
- A PSR-18 HTTP client and PSR-17 factories (see Installation)
Long running runtimes are supported. The services that cache per-request values are reset between requests, so
FrankenPHP worker mode, RoadRunner and Swoole do not leak one visitor's fbp or fbc into the next visitor's
events.
composer require setono/meta-conversions-api-bundleThe SDK sends events through a PSR-18 client and PSR-17 factories, which it discovers automatically. If your application does not already ship both, install them alongside the bundle:
composer require setono/meta-conversions-api-bundle symfony/http-client nyholm/psr7The SDK depends on php-http/discovery, which contains a Composer plugin. Composer
asks whether to allow it, and either answer works. To skip the prompt (e.g. in CI) declare it in your composer.json:
{
"config": {
"allow-plugins": {
"php-http/discovery": false
}
}
}Installing the bundle also installs the Bot Detection Bundle, which is used to filter bot requests.
If you want to handle consent (i.e. cookie/GDPR consent), install the consent bundle
and enable the consent option (see Configuration):
composer require setono/consent-bundleWhile consent is not granted, events are still built and enriched, but nothing leaves your server and nothing is
stored on the visitor's device: no fbq() calls are rendered, no SendEvent is dispatched, and neither the _fbp
nor the _fbc cookie is written. Each of those is logged at debug level, see
Why did my event not show up?.
If the consent bundle is not installed, or the consent option is off, everything is treated as granted.
Upgrading from 0.1.x? See UPGRADE.md.
All options with their defaults:
# config/packages/setono_meta_conversions_api.yaml
setono_meta_conversions_api:
# Only track when the visitor has granted consent. Requires the consent bundle (see above)
consent:
enabled: false
# The consent category that must be granted, see Setono\Consent\DefaultConsents
category: marketing
# Client side tracking, i.e. rendering the Meta pixel and fbq() calls in the browser.
# Requires setono/tag-bag-bundle ^3.0 and defaults to enabled when that bundle is installed
client_side:
enabled: false
# Server side tracking, i.e. sending the events to the Conversions API through Symfony Messenger
server_side:
enabled: true
# The Messenger bus the SendEvent command is dispatched on. Defaults to your application's default bus
message_bus: messenger.default_bus
# The pixels to send events to (empty by default). Alternatively provide pixels from your own source by
# aliasing Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface to your own service.
# The access token is only needed for server side tracking: client side tracking renders fbq() calls, which
# only need the pixel id. A pixel without an access token is skipped server side, with a warning in the log
pixels:
- id: '%env(META_PIXEL_ID)%'
access_token: '%env(META_ACCESS_TOKEN)%'
# The PSR-18 http client used to send events. Defaults to Symfony's default http client, which means requests
# to Meta show up in the profiler and honour the options you configured. Point it at a scoped client to give
# Meta its own timeout
http_client: psr18.http_client
# Send events as test events, so they show up under 'Test events' in Meta's event manager instead of counting
# as real conversions
test_event_code:
# If enabled, ?_testEventCode=... sets the test event code for the rest of the visitor's session.
# Defaults to the value of kernel.debug, i.e. enabled in dev and disabled in prod
query_parameter: '%kernel.debug%'
# A static test event code applied to every event, e.g. on a staging environment
value: null
filters:
# Regular expression fragments without delimiters, matched case insensitively. Events with a matching user
# agent are not tracked. Invalid fragments, and fragments containing an unescaped '#', fail at compile time
user_agent: []Server side events are dispatched on your application's default Messenger bus. The bundle does not register a bus of
its own, so your bus configuration is left untouched. Point the bundle at another bus with the server_side.message_bus
option if you prefer.
Route the command to an async transport. Without it, Messenger handles the command synchronously, which means the http call to Meta happens inside the visitor's request: their page waits for Meta's round trip, and Meta's availability becomes your availability.
# config/packages/messenger.yaml
framework:
messenger:
routing:
'Setono\MetaConversionsApiBundle\Message\Command\SendEvent': asyncEvery command the bundle dispatches implements
Setono\MetaConversionsApiBundle\Message\Command\CommandInterface, so you can route them as a group instead.
With a transport, Messenger also retries a failed send and moves it to the failure transport when it keeps failing.
Either way, a send that fails is logged as an error and never propagates into the response, so an expired access token or an outage at Meta cannot break the page.
<?php
declare(strict_types=1);
use Psr\EventDispatcher\EventDispatcherInterface;
use Setono\MetaConversionsApi\Event\Event;
use Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised;
final class YourService
{
public function __construct(private readonly EventDispatcherInterface $eventDispatcher)
{
}
public function track(): void
{
$event = new Event(Event::EVENT_VIEW_CONTENT);
$event->customData->contentType = 'product';
$event->customData->contentName = 'Blue Jeans';
$event->customData->contentIds[] = 'PRODUCT_SKU';
$this->eventDispatcher->dispatch(new ConversionsApiEventRaised($event));
}
}Dispatching a ConversionsApiEventRaised runs the event through a pipeline of listeners. The bundle populates the
event first, then leaves a gap for your own listeners, then filters and sends:
| Priority | Listener | What it does |
|---|---|---|
PRIORITY_POPULATE (1000) |
PopulateRequestPropertiesSubscriber |
Source url, client ip and user agent from the request |
| 900 | PopulateFbpAndFbcPropertiesSubscriber |
fbp and fbc |
| 800 | PopulateTestEventCodePropertySubscriber |
Test event code |
| 650 | FilterEmptyUserAgentSubscriber |
Stops events without a user agent |
| 625 | FilterConfiguredUserAgentsSubscriber |
Stops events matching filters.user_agent |
PRIORITY_FILTER (600) |
FilterBotsSubscriber |
Stops events from bots |
| 500 | PopulatePixelsSubscriber |
Pixels from the pixel provider |
PRIORITY_ENRICH (0) |
your listeners | Email, phone, external id, custom data |
| -950 | StopPropagationIfNoPixelsHasBeenAddedSubscriber |
Stops events without pixels |
PRIORITY_SEND (-1000) |
AddEventToTagBagSubscriber |
Renders the fbq() calls (client side) |
PRIORITY_SEND (-1000) |
DispatchOnCommandBusSubscriber |
Dispatches SendEvent (server side) |
Two things follow from this:
- Enrich at
PRIORITY_ENRICH, which is the default priority of any listener. Everything the bundle knows about the request is populated by then, and traffic the bundle does not want to track has already been discarded, so your listeners never do work for a bot. - A listener below
PRIORITY_ENRICHmay never run, because propagation can already have been stopped.
The constants live on ConversionsApiEventRaised, so you can position your listener without hard coding a number.
Everything the Conversions API can do beyond the browser pixel comes from the user data you attach server side. Meta normalises and hashes it for you, so set the raw values:
<?php
declare(strict_types=1);
namespace App\EventListener;
use Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(priority: ConversionsApiEventRaised::PRIORITY_ENRICH)]
final class AddCustomerToConversionsApiEvent
{
public function __construct(private readonly Security $security)
{
}
public function __invoke(ConversionsApiEventRaised $event): void
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
$userData = $event->event->userData;
$userData->email[] = $user->getEmail();
$userData->firstName[] = $user->getFirstName();
$userData->lastName[] = $user->getLastName();
$userData->externalId[] = (string) $user->getId();
}
}You can also replace a step instead of adding to it: alias PixelProviderInterface, FbpContextInterface or
FbcContextInterface to your own service, or register a listener above the corresponding populate priority.
The second constructor argument of ConversionsApiEventRaised carries anything your listeners need but that must
never be sent to Meta, such as the order the event was raised for. The bundle never reads it.
$this->eventDispatcher->dispatch(new ConversionsApiEventRaised($event, ['order' => $order]));
// in a listener
if ($event->hasContext('order')) {
$order = $event->getContext('order');
}With both sides enabled the same event is deliberately sent twice: once by the browser pixel and once through the
Conversions API. Meta collapses the pair because both carry the same id. Event::$eventId is generated in the
constructor and rendered as eventID in the fbq() call and sent as event_id in the api payload, so a single
dispatch is deduplicated for you.
It only breaks if you assign ids yourself. If you do, use the same id on both sides, and make sure a page reload does not reuse an id from a previous page view.
See Meta's deduplication documentation.
Besides whatever you attach yourself, the bundle fills in:
| Field | Where it comes from |
|---|---|
event_source_url |
The full url of the current request, including its query string |
client_ip_address |
Request::getClientIp() |
client_user_agent |
The User-Agent header |
fbp, fbc |
The _fbp and _fbc cookies, or the fbclid query parameter |
Two things are worth checking in your application:
- Configure
framework.trusted_proxies. Without itgetClientIp()returns your load balancer's address, and every visitor looks like they came from the same place. - The query string is sent as is. If your urls carry anything you would rather not hand to Meta, strip it in a
listener at
ConversionsApiEventRaised::PRIORITY_ENRICH:$event->event->eventSourceUrl = strtok((string) $event->event->eventSourceUrl, '?');
Personal data is normalised and hashed with SHA-256 by the SDK before it leaves your server, so raw email addresses and phone numbers are never sent, and they are not written to the Messenger transport either.
Every listener that drops an event says so at debug level on the setono_meta_conversions_api Monolog channel: the
bot filter, the user agent filters, the no-pixels check, and each of the three consent gates. The send handler logs a
warning when a pixel has no access token.
# config/packages/monolog.yaml
monolog:
handlers:
meta:
type: stream
path: '%kernel.logs_dir%/meta.log'
level: debug
channels: ['setono_meta_conversions_api']The pipeline assumes the event belongs to the request being handled. PopulateRequestPropertiesSubscriber therefore
fills in the source url, client ip and user agent of the current request, and the bot and user agent filters only
apply to events whose actionSource is website (the default).
For an event raised from a console command, a message handler or an incoming webhook, set another action source so the filters leave it alone:
$event = new Event(Event::EVENT_PURCHASE, Event::ACTION_SOURCE_SYSTEM_GENERATED);If such an event is raised while handling an HTTP request, for instance a webhook from your payment provider, the
request properties still describe that request, not the customer. Overwrite them in a listener above
PRIORITY_POPULATE when they matter.
Because the client is a normal service, a scoped client works out of the box:
framework:
http_client:
scoped_clients:
meta.client:
base_uri: 'https://graph.facebook.com'
timeout: 2
max_duration: 5
setono_meta_conversions_api:
http_client: meta.clientNote that a scoped client is a Symfony HttpClientInterface, so wrap it for PSR-18:
services:
meta.psr18_client:
class: Symfony\Component\HttpClient\Psr18Client
arguments: ['@meta.client']
setono_meta_conversions_api:
http_client: meta.psr18_clientEvents are posted to the Graph API version of the installed facebook/php-business-sdk package (the SDK reads
ApiConfig::APIVersion): v26.0 with the 26.x package, v25.0 with 25.x. To move to a newer Graph API version simply run
composer update facebook/php-business-sdk.
Take the test event code from Meta / Facebook's event manager and append it to any url on your website:
https://example.com/?_testEventCode=[YOUR TEST EVENT CODE] (or ?_test_event_code=[YOUR TEST EVENT CODE]). The code
is saved in the session, so all your subsequent requests are sent with it. Clear it again with an empty value:
https://example.com/?_testEventCode=.
Because anyone who can add a query parameter would otherwise be able to divert their own conversions into the test
bucket, the query parameter is only honoured when test_event_code.query_parameter is enabled. It follows
kernel.debug by default, so it works in dev and is off in prod. To send every event as a test event, for
instance from a staging environment, set test_event_code.value instead.