setono/client-id-bundle
A Symfony bundle that generates and persists a unique client ID, helping identify anonymous visitors across requests for analytics, tracking, or personalization without requiring users to log in.
Install and enable the bundle; there is no Flex recipe for a package this small, so registration is manual:
composer require setono/client-id-bundle
// config/bundles.php
return [
// ...
Setono\ClientIdBundle\SetonoClientIdBundle::class => ['all' => true],
];
The bundle's job is narrow: give you a stable, server-generated visitor ID and persist it in a first-party cookie. Start by finding the two extension points in the source:
src/ClientId/ — the ClientIdProviderInterface (and its default implementation). This is what you inject everywhere.src/EventListener/ (or src/EventSubscriber/) — the request/response listener that reads the cookie and writes a new one when absent.src/DependencyInjection/Configuration.php — the source of truth for config keys.Dump the reference before writing config, since the tree is small and keys are easy to guess wrong:
bin/console config:dump-reference setono_client_id
A typical configuration looks like this (confirm names against the dump):
# config/packages/setono_client_id.yaml
setono_client_id:
cookie_name: setono_client_id
cookie_lifetime: 31536000 # one year
cookie_secure: auto
cookie_http_only: true
cookie_same_site: lax
As of v0.2.3 there is also a compatibility layer with setono/client-bundle. If your app already pulls that bundle in (common when you use other Setono tracking packages), this bundle now bridges its ClientId concept to the shared one, so a single visitor ID flows through both. Check what actually got wired rather than assuming:
bin/console debug:container ClientIdProviderInterface
bin/console debug:container 'Setono\ClientBundle\ClientId\*' # if client-bundle is installed
If the aliases resolve to the same underlying service, you are done — new code can type-hint whichever interface your other Setono packages already use. If client-bundle is not installed the compat layer is inert and nothing changes for you.
The first real use case is server-side analytics — feeding GA4 Measurement Protocol, Facebook CAPI, or your own event store with a client_id that does not depend on JavaScript:
use Setono\ClientIdBundle\ClientId\ClientIdProviderInterface;
final class MeasurementProtocolClient
{
public function __construct(
private ClientIdProviderInterface $clientIdProvider,
private HttpClientInterface $httpClient,
) {}
public function track(string $eventName, array $params): void
{
$this->httpClient->post('https://www.google-analytics.com/mp/collect?api_secret=...&measurement_id=...', [
'json' => [
'client_id' => (string) $this->clientIdProvider->getClientId(),
'events' => [['name' => $eventName, 'params' => $params]],
],
]);
}
}
If the service autowires, you are done. If not, alias the interface to the bundle's default implementation in services.yaml — but see the gotcha below about the compat layer and alias ordering before you override anything.
Inject the provider, not the cookie. Treat ClientIdProviderInterface as the only API. It handles "read existing cookie or mint a new one" so your tracking services never touch $_COOKIE or Request directly. Decorate the interface if you need a different generation strategy (UUIDv7, ULID, or adoption of an existing _ga value).
Pick one interface and standardise on it (new in 0.2.3). With the setono/client-bundle bridge in place there are now potentially two contracts pointing at the same value. Sharing code with other Setono packages is easier if you type-hint the client-bundle interface; staying self-contained is easier if you keep Setono\ClientIdBundle\ClientId\ClientIdProviderInterface. Either way, do not mix both across your codebase — you will get two decorators registered against two different service IDs and wonder why your GA-aware provider is bypassed on half the call sites.
Reconcile with client-side IDs. If you already run GA4 via gtag, the browser holds a _ga cookie of the form GA1.1.<client_id>. A decorator can parse it and return that value, so server-side and client-side hits land on the same user:
final class GaCookieAwareProvider implements ClientIdProviderInterface
{
public function __construct(
private ClientIdProviderInterface $inner,
private RequestStack $requestStack,
) {}
public function getClientId(): ClientId
{
$ga = $this->requestStack->getMainRequest()?->cookies->get('_ga');
if (is_string($ga) && preg_match('/^GA\d+\.\d+\.(.+)$/', $ga, $m)) {
return new ClientId($m[1]);
}
return $this->inner->getClientId();
}
}
Register it as a decorator of the bundle's service. With the compat layer active, decorate the shared service ID so both interfaces see your decorator.
Keep tracking off the request path. Sending Measurement Protocol or CAPI calls inline adds latency to every page view. Dispatch a Messenger message carrying the client ID string and do the HTTP call in a worker. The ID is just a string — snapshot it, don't serialize the provider.
Respect consent. Generate and set the cookie only when consent allows it. Wrap the provider in a consent-aware decorator that throws or returns a null-object ID when consent is missing, and check that the bundle's listener does not write the cookie unconditionally. This is the most common compliance failure with server-side ID packages. If you decorate now, make sure the decorator sits outside the compat bridge, not underneath it, or the other bundle will still read the raw ID.
Use it as a correlation key, not a user account. The value is stable per browser/cookie jar. Store it alongside your own events table so you can join server-side purchases back to sessions, but never treat it as an authenticated identity.
Testing. In unit tests, mock the interface. In functional tests, use KernelBrowser and assert the Set-Cookie header on the first response and that the provider returns the same value on a second request that replays the cookie. Add one test that asserts both interfaces (this bundle's and client-bundle's, if installed) return an identical value — that is the contract the 0.2.3 compat layer promises, and it is the thing most likely to regress in a future minor.
setono/client-bundle being present. If you register bundles in an unusual order, or you enable SetonoClientIdBundle in one kernel and SetonoClientBundle in another (e.g. a separate admin kernel), the alias may resolve differently per kernel. Keep debug:container output from each kernel in your notes.ClientIdProviderInterface to your own implementation in services.yaml, and the compat layer also registers an alias, the last one loaded wins. Load your overrides after the bundle config, or use a decorator instead of an alias."setono/client-id-bundle": "^0.2.3" and keep an integration test asserting the cross-bundle ID equality so a future refactor cannot quietly break attribution.ClientIdProvider before building on it.kernel.response. If you short-circuit with a streamed response, an early return new JsonResponse(...) from a listener at higher priority, or work in kernel.terminate, the Set-Cookie header may never reach the client.Set-Cookie is effectively uncacheable and can poison a shared cache (Varnish, Cloudflare, Symfony HttpCache). Either exclude the cookie-setting response from caching, add Vary: Cookie, or set the cookie only on HTML navigations.secure: auto depends on Request::isSecure(). Without trusted_proxies / trusted_headers configured for your load balancer, the cookie silently loses Secure and may be rejected in modern browsers.www. and app., set the cookie domain to .example.com, otherwise each subdomain mints a separate client ID and your funnel attribution breaks. This matters double once client-bundle is in play, because two packages reading two different cookies is harder to spot than one.SameSite trade-off. Lax (the sane default) means the cookie is not sent on cross-site POSTs. If you receive server-to-server callbacks or cross-origin form posts that need the ID, you need None; Secure and a real HTTPS setup.Set-Cookie in the response → listener not registered or response short-circuited. Cookie present but ID changes each request → wrong cookie name/domain/path, or the provider is generating instead of reading. ID differs between PHP-FPM and CLI → expected; never compare across contexts. ID differs between this bundle's provider and client-bundle's after 0.2.3 → the compat alias is not registered, or your own decorator is only attached to one side.kernel.response yourself and compare against the request cookie.composer.json, read the source once end-to-end (it is small), and add an integration test that asserts the cookie contract so an upgrade cannot silently break tracking.How can I help you explore Laravel packages today?