theiconic/php-ga-measurement-protocol
A PHP client for the Google Analytics Measurement Protocol, letting you send server-side hits like pageviews, events, and ecommerce transactions directly to Google Analytics from your Laravel or PHP app without client-side JavaScript.
Install with Composer (pin to the release that has batch support):
composer require theiconic/php-ga-measurement-protocol:^2.9
The whole API is a fluent builder on TheIconic\Tracking\GoogleAnalytics\Analytics. You set parameters one at a time, then call a send*() method for the hit type:
use TheIconic\Tracking\GoogleAnalytics\Analytics;
$analytics = new Analytics(true); // true = force HTTPS
$analytics
->setProtocolVersion('1')
->setTrackingId('UA-123456-1')
->setClientId('1234567890.1600000000')
->setDocumentPath('/checkout/success')
->setDocumentTitle('Order complete')
->setAnonymizeIp(true);
$analytics->sendPageview();
$response = $analytics->getResponse();
if (! $response->isSuccessful()) {
// getHttpStatusCode() / log for later inspection
}
Where to look first
src/Analytics.php — the fluent setters, the send*() entry points (sendPageview, sendEvent, sendScreenview, sendTransaction, sendItem, sendException, sendTiming, sendSocialInteraction) and the response accessor. As of 2.9.0 this is also where the new batch-hit entry point lives — read the method and its tests before using it, since the release note is a one-liner and there is no prose documentation for it yet.src/Parameters/ — the catalogue of what you can actually set, grouped by scope (Hit/, Session/, TrafficSources/, ECommerce/, System/). This is your reference when a setter name isn't obvious.src/Network/HttpClient.php — SSL, timeouts, async mode, proxy, and how the query string is assembled. Batch support changes what this sends, so re-read it if you adopt batching.src/Exception/ — InvalidParameterException (unknown setter name) and InvalidPayloadException (missing required parameters for a hit type).First use case
The natural first adoption is the thing JavaScript can't do: a hit that fires from a webhook, queue worker, cron, or CLI command. A payment provider confirms an order → you send a server-side pageview or event with the same client_id the browser used, so the conversion is attributed to the real session rather than appearing as a brand-new anonymous user. If you're already doing that at any volume, 2.9.0's batch support is the first upgrade worth taking.
Wrap it in a configured factory, not a singleton
Analytics accumulates state across calls. Bind a small factory that produces a fresh instance per hit, pre-loaded with your defaults:
final class AnalyticsFactory
{
public function __construct(
private string $trackingId,
private ?string $endpoint = null,
) {}
public function forClient(string $clientId): Analytics
{
$analytics = new Analytics(true);
$analytics
->setProtocolVersion('1')
->setTrackingId($this->trackingId)
->setClientId($clientId)
->setDataSource('server')
->setAnonymizeIp(true)
->setRequestTimeout(2);
if ($this->endpoint) {
$analytics->setEndpoint($this->endpoint);
}
return $analytics;
}
}
Resolve it from the container, keep the tracking ID in config/env, and never reuse the returned object for a second hit.
Stitch the client ID to the browser session
For attribution to work, the server-side hit must carry the same cid the browser tracker uses. Parse it out of the _ga cookie rather than inventing one:
$clientId = null;
if (! empty($_COOKIE['_ga'])) {
// _ga looks like: GA1.2.1234567890.1600000000
if (preg_match('/^GA\d+\.\d+\.(\d+\.\d+)$/', $_COOKIE['_ga'], $m)) {
$clientId = $m[1];
}
}
$clientId ??= $user->analytics_client_id ?? Str::uuid()->toString();
Persist a generated ID on the user record if you also send hits from unauthenticated contexts (emails, webhooks) so repeated server hits group into one user. setUserId() is useful as a cross-device join key, but it does not replace client_id.
Decouple the request from the hit
Analytics calls are network I/O. On latency-sensitive paths (checkout, login, API responses), push the hit description into a queue and build the Analytics object in the worker:
final class SendAnalyticsHit implements ShouldQueue
{
public function __construct(
public string $type, // 'pageview', 'event', ...
public array $params,
public string $clientId,
) {}
public function handle(AnalyticsFactory $factory): void
{
$analytics = $factory->forClient($this->clientId);
foreach ($this->params as $setter => $value) {
$analytics->{$setter}($value);
}
$analytics->{'send' . ucfirst($this->type)}();
if (! $analytics->getResponse()->isSuccessful()) {
// log / retry policy
}
}
}
This keeps the request fast, makes retries explicit, and lets you add sampling or rate limiting in one place. Store the parameters as plain scalars — don't try to serialize the Analytics or Payload object.
Batch hits (new in 2.9.0)
2.9.0 adds a batch hit feature, which means you no longer have to hand-roll the /batch/collect body. The Measurement Protocol rules still apply: at most 20 hits per request and a hard payload size ceiling (16 KB), and every hit in the batch must carry its own tid/cid/t — hits do not inherit parameters from each other.
Treat it as a queued flush rather than a per-request call:
// Shape only — confirm the exact method names against
// src/Analytics.php and the package tests before relying on this.
$analytics = $factory->forClient($clientId);
$analytics->setBatchHits($bufferedHits); // array of ParameterBag-style hit definitions
$analytics->sendBatch();
$response = $analytics->getResponse();
Practical guidance regardless of the exact API:
analytics_hits queue table) and send one batch per N hits or per time window — e.g. from a scheduled command running every minute. Don't build a batch inside a web request.isSuccessful() alone won't tell you which hits failed. Parse the response in your flush job and log the failing indices.Async mode for genuinely fire-and-forget hits
$analytics->setAsyncRequest(true);
$analytics->sendEvent();
This opens a non-blocking socket and returns immediately. You get no usable response, so errors vanish. It's appropriate for high-volume, best-effort instrumentation (e.g. backend job metrics) and inappropriate for conversions you may need to debug or reconcile. Async and batch solve different problems — batching cuts request count, async cuts latency — and combining them gives you essentially no delivery signal.
E-commerce: transaction then one hit per item
$analytics->setTransactionId($order->id)
->setTransactionAffiliation('Web')
->setTransactionRevenue($order->total)
->setTransactionShipping($order->shipping)
->setTransactionTax($order->tax)
->setTransactionCurrency('EUR')
->sendTransaction();
foreach ($order->items as $item) {
$analytics->setTransactionId($order->id)
->setItemName($item->name)
->setItemCode($item->sku)
->setItemCategory($item->category)
->setItemPrice($item->price)
->setItemQuantity($item->quantity)
->sendItem();
}
Set every item parameter on every iteration — the builder retains previous values, so an omitted setItemCategory() silently reuses the last item's category. Item hits require the transaction ID; without it they're dropped or orphaned. This is the highest-value candidate for batching, since a 10-item order is 11 requests today.
Instrument backend events as GA events, not fake pageviews
$analytics
->setEventCategory('account')
->setEventAction('signup_confirmed')
->setEventLabel($plan->slug)
->setEventValue(1)
->sendEvent();
Same for sendTiming() (job duration) and sendException() (background worker failures) if you want operational data alongside product analytics. Keep these out of user-facing properties unless you're deliberately aggregating them.
Validate with the debug endpoint during development
$analytics->setEndpoint('https://www.google-analytics.com/debug/collect');
The response body is JSON with per-hit validation results, which is the only real way to know whether Google accepted the payload. Point a config flag at this endpoint in local/staging and at the normal endpoint in production. If the batch feature doesn't expose a debug-endpoint equivalent, validate your hit definitions individually against /debug/collect before you start batching them.
Testing
Inject a spy HTTP client (setHttpClient()) or point setEndpoint() at a local recording endpoint; then assert on the outgoing query string (or batch body). Combine that with a small set of integration tests against /debug/collect to catch invalid parameter combinations. Keep tests asserting your payload construction, not Google's behaviour.
UA-XXXX-Y). UA stopped processing standard hits in July 2023. If your property is GA4, the payload is fundamentally different: measurement_id + api_secret, a JSON body POSTed to /mp/collect, and events nested under events[]. You can override the endpoint, but the client builds a query string, not a JSON body, so you'd be fighting the transport — and the new batch feature batches UA hits, not GA4 events. For new work, evaluate a GA4-native client or a thin Http-based implementation instead of forking this one./collect endpoint returns a 1×1 GIF for almost anything, including malformed hits. isSuccessful() only tells you the request reached Google. Use /debug/collect to distinguish "sent" from "valid."cid per request and every hit is a new user: sessions fragment, conversions never attribute, and reports are noise. Read the _ga cookie or persist an ID per user.setDocumentPath() from a pageview carries into a subsequent event on the same object, and e-commerce item parameters persist across items. Always build a fresh Analytics per hit; treat it as a value object, never a singleton. This bites hardest in batch construction: if you reuse one builder to assemble 20 hits, every hit inherits the last one's parameters.setDocumentPathh() throws InvalidParameterException at call time. The generic setParameter('cd1', $value) escape hatch bypasses that safety net — verify parameter names against the Measurement Protocol docs, and prefer the named setters where they exist.InvalidPayloadException before the request. Useful, but the library only validates what it models — semantic mistakes (wrong currency, revenue as a formatted string, missing ti on item hits) still go through silently. Batch mode makes this worse: one bad hit can invalidate a chunk, so validate first.documentPath, event labels, or custom dimensions. GA's terms forbid it and it turns an analytics call into a GDPR/CCPA liability. Use internal IDs, and enable setAnonymizeIp(true) for EU traffic.setEndpoint() is your main extension point — local capture servers, proxies, regional endpoints, and debug validation all go through it. setHttpClient() is the second for testing or custom transport. Beyond that, adding first-class setters means adding parameter classes under src/Parameters and letting the setter resolution pick them up; check Analytics/ParameterSetter before assuming a name will resolve.How can I help you explore Laravel packages today?