google/analytics-data
Idiomatic PHP client for Google Analytics Data, supporting REST and gRPC. Install via Composer, authenticate with Google Cloud, and query analytics reports with ready-made client classes.
Install the generated client and authenticate with a service account that has at least Viewer access on the GA4 property:
composer require google/analytics-data
// config/services.php
'google_analytics' => [
'property_id' => env('GA4_PROPERTY_ID'), // e.g. "properties/123456789"
'credentials' => env('GOOGLE_APPLICATION_CREDENTIALS'),
],
// app/Providers/AnalyticsServiceProvider.php
use Google\Analytics\Data\V1beta\Client\BetaAnalyticsDataClient;
$this->app->singleton(BetaAnalyticsDataClient::class, fn () => new BetaAnalyticsDataClient([
'credentials' => config('services.google_analytics.credentials'),
]));
Three things must be true before your first request succeeds, and each fails with a different error:
PERMISSION_DENIED / "API has not been used" otherwise).[email protected]) is added as a user on the GA4 property in Admin → Property Access Management (PERMISSION_DENIED with a valid token).properties/<numeric id>, not the bare number and not the measurement ID (G-XXXXXXX).First use case: a headline dashboard — sessions and active users for the last 28 days, grouped by channel:
use Google\Analytics\Data\V1beta\{DateRange, Dimension, Metric, RunReportRequest};
$request = (new RunReportRequest())
->setProperty(config('services.google_analytics.property_id'))
->setDimensions([new Dimension(['name' => 'sessionDefaultChannelGroup'])])
->setMetrics([new Metric(['name' => 'sessions']), new Metric(['name' => 'activeUsers'])])
->setDateRanges([new DateRange(['start_date' => '28daysAgo', 'end_date' => 'yesterday'])])
->setLimit(20);
$response = app(BetaAnalyticsDataClient::class)->runReport($request);
foreach ($response->getRows() as $row) {
$channel = $row->getDimensionValues()[0]->getValue();
$sessions = (int) $row->getMetricValues()[0]->getValue();
$users = (int) $row->getMetricValues()[1]->getValue();
}
Look first at the official Data API v1 docs for dimension/metric names, then at the generated PHP API reference for exact setters. Don't guess names — call getMetadata() against the property and cache the result; it enumerates every dimension and metric actually available (including custom ones like customEvent:signup_completed).
Wrap the client, don't sprinkle it. A thin repository (AnalyticsRepository::report(ReportQuery $q): ReportResult) that owns request building and row normalization gives you one place to cache, retry, log, and fake in tests. The generated client is awkward to mock directly; an interface is worth the ten lines.
Build a request DTO rather than chaining setters in controllers. Requests are immutable-ish protobuf messages; assembling them in a value object lets you reuse a "base report" (property, date range, filters) and vary dimensions per screen.
Pagination is limit/offset, not cursors. Default is 10,000 rows; the API caps a single request at 250,000. For exports, loop:
$offset = 0;
do {
$response = $client->runReport($base->setLimit(10000)->setOffset($offset));
foreach ($response->getRows() as $row) { /* accumulate */ }
$offset += 10000;
} while ($offset < $response->getRowCount());
For scheduled exports, prefer a queued job writing to a table over a synchronous request — GA4 responses for 100k+ rows take seconds to tens of seconds and will blow through PHP max_execution_time.
Use batchRunReports for dashboards with several cards. One HTTP round trip carries up to five RunReportRequests, which cuts latency and, more importantly, concurrent-request pressure on quota. Same for batchRunPivotReports.
Filters. FilterExpression is a tree: Filter, FilterExpressionList (AND/OR group), notExpression. In practice you get one level of nesting — an andGroup of orGroups or the reverse. Dimension filters support EXACT, BEGINS_WITH, ENDS_WITH, CONTAINS, FULL_REGEXP, PARTIAL_REGEXP, and inListFilter for small bounded sets; metric filters take numeric comparisons.
use Google\Analytics\Data\V1beta\{Filter, FilterExpression, FilterExpressionList};
$byChannel = (new FilterExpression())->setFilter(new Filter([
'field_name' => 'sessionDefaultChannelGroup',
'string_filter' => new Filter\StringFilter([
'match_type' => Filter\StringFilter\MatchType::EXACT,
'value' => 'Organic Search',
]),
]));
$request->setDimensionFilter(
(new FilterExpression())->setAndGroup(new FilterExpressionList(['expressions' => [$byChannel, $byDevice]]))
);
Ordering. orderBys is required for "top N" semantics — otherwise you get arbitrary rows truncated at limit. For dimension sorts choose OrderType::ALPHANUMERIC for things like date or pagePath if lexicographic order is wrong.
Real-time. runRealtimeReport() covers roughly the last 30 minutes and has a reduced dimension/metric surface plus no date ranges. It's for a live counter widget, not for reporting — don't try to feed it into the same aggregation pipeline as runReport.
Caching. GA4 recomputes reports server-side on every request; there's no response cache. A 5–15 minute cache keyed on md5(serialize($query)) is the single highest-leverage change for a dashboard. Cache getMetadata() for hours, not minutes.
Quota awareness. Set ->setReturnPropertyQuota(true) on your heaviest request and log getPropertyQuota() (tokens per day/hour, concurrent requests, consumed/remaining). Wire a circuit-breaker or a degraded "data delayed" state in the UI when remaining is near zero.
Client namespace moved. Older releases expose Google\Analytics\Data\V1beta\BetaAnalyticsDataClient; newer ones use Google\Analytics\Data\V1beta\Client\BetaAnalyticsDataClient. Sample code and blog posts are split between the two. If an import breaks after composer update, check for the Client\ subnamespace first — this is the most common upgrade failure. The old class still works but is deprecated.
Everything is a string. getValue() returns string for integers, floats, percentages, and currency alike. Cast deliberately per metric ((int) for sessions, (float) for engagementRate, (float) for totalRevenue) and beware that empty/absent metrics are often "" or "0" rather than null.
keepEmptyRows defaults to false. Rows whose metrics are all zero are dropped. This silently changes "% of total" math and makes date series have holes. Set ->setKeepEmptyRows(true) for time series, and leave it off for top-N lists.
Metric compatibility. Metrics have scopes (event-based vs session-based). Combining certain metrics in one request fails with INVALID_ARGUMENT about incompatible metrics. When it happens, split into two requests and join in PHP — don't hunt for a magic combination.
Sampling and thresholding. Check getMetadata()->getSamplingMetadatas() before publishing numbers; large cardinality dimensions over long ranges get sampled. Also check getMetadata()->getSubjectToThresholding() — when demographics or signals are involved, rows may be withheld for privacy and totals won't add up.
Row count is not row count. getRowCount() is the total matching rows across pagination; count(getRows()) is what came back on this page. Paginating past the end returns zero rows with a nonzero rowCount — guard your do/while.
Date semantics are property-local. start_date/end_date accept YYYY-MM-DD, today, yesterday, NdaysAgo; interpretation uses the property's time zone (getMetadata()->getTimeZone()), not your server's or the user's. "Yesterday" in a UTC app can differ from the property's yesterday.
Currency is not formatted. totalRevenue comes back as a bare number; the ISO code lives in getMetadata()->getCurrencyCode(). Formatting is entirely yours, and for multi-property setups the code differs per property.
PERMISSION_DENIED has three causes and the message rarely distinguishes them: API not enabled, service account not added to the property, or wrong property ID format. Validate properties/\d+ with a regex before the API call — it saves a lot of support time.
Transport. Defaults to gRPC if ext-grpc is installed, otherwise REST. On shared hosting, containers without the extension, or behind proxies that mangle HTTP/2, force REST explicitly: new BetaAnalyticsDataClient(['transport' => 'rest']). REST is fine for this API; streaming isn't a factor.
Retries. The client has retry settings for UNAVAILABLE/DEADLINE_EXCEEDED, but 429 RESOURCE_EXHAUSTED needs backoff on your side. Add a small exponential backoff in your repository wrapper, and never retry inside a request loop over 200 rows.
Error type. All failures surface as Google\ApiCore\ApiException; use $e->getStatus() (a ApiStatus constant) or $e->getBasicMessage() rather than parsing getMessage(). Map ApiStatus::RESOURCE_EXHAUSTED and ApiStatus::PERMISSION_DENIED to distinct user-facing states.
Testing. Use Google\ApiCore\Testing\MockTransport (or MockHandler) injected via the client's transport option to return canned protobuf responses. Better: code against your own interface and bind a fake in the container so the vast majority of tests never touch google/analytics-data at all.
Extension points worth knowing: client options credentials, transport, apiEndpoint, universeDomain, and clientConfig for custom retry/timeout settings. The message classes are plain protobuf, so serializeToJsonString() and mergeFromJsonString() are handy for logging requests/responses to your Laravel log without leaking credentials.
Cost of cardinality. Dimensions like fullPageUrl, pageTitle, or customEvent:* explode row counts and token consumption. Add limit + orderBys and a dimensionFilter narrowing to real traffic (sessionDefaultChannelGroup not equal to "Direct", bots excluded) before you reach for pagination.
How can I help you explore Laravel packages today?