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.
This is a generated Google Cloud client, not a Laravel package. There is no service provider, no facade, no config file. You are wrapping a gRPC/REST SDK. Treat it as infrastructure, not application code.
1. Install
composer require google/analytics-data
You do not need the gRPC extension — the client falls back to REST over HTTP/1.1, which is fine (and simpler) for typical Laravel report endpoints.
2. Get credentials — this is 90% of the setup cost
...iam.gserviceaccount.com email as a Viewer. Enabling the API is not enough; the service account must be a user on the property itself. Most "PERMISSION_DENIED" tickets stop here.3. Point the client at the credentials
Store the JSON outside public/ and reference it by absolute path:
// config/services.php
'ga4' => [
'property_id' => env('GA4_PROPERTY_ID'),
'credentials' => env('GA4_CREDENTIALS_PATH'), // absolute path recommended
],
Simplest wiring is the standard ADC env var (GOOGLE_APPLICATION_CREDENTIALS=/abs/path/key.json) so new BetaAnalyticsDataClient() needs no arguments.
4. First report
use Google\Analytics\Data\V1beta\Client\BetaAnalyticsDataClient;
use Google\Analytics\Data\V1beta\RunReportRequest;
use Google\Analytics\Data\V1beta\DateRange;
use Google\Analytics\Data\V1beta\Dimension;
use Google\Analytics\Data\V1beta\Metric;
$client = new BetaAnalyticsDataClient();
$request = (new RunReportRequest())
->setProperty('properties/'.config('services.ga4.property_id'))
->setDateRanges([new DateRange(['start_date' => '7daysAgo', 'end_date' => 'yesterday'])])
->setDimensions([new Dimension(['name' => 'sessionDefaultChannelGroup'])])
->setMetrics([new Metric(['name' => 'sessions'])])
->setLimit(10);
$response = $client->runReport($request);
foreach ($response->getRows() as $row) {
[$channel] = $row->getDimensionValues();
[$sessions] = $row->getMetricValues();
dump($channel->getValue(), (int) $sessions->getValue());
}
Where to look first
getMetadata() on the client to enumerate valid dimensions/metrics per property (cache this; it rarely changes).checkCompatibility() before shipping any new report — it fails fast on invalid dimension/metric pairs instead of letting you find out in production.Ignore v1alpha unless you specifically need ReportTask / audience lists — that's the async bulk-export surface, irrelevant for dashboards.
Bind the client once. Create it per-request and you pay TLS/credential handshake every call.
// AppServiceProvider::register()
$this->app->singleton(BetaAnalyticsDataClient::class, function () {
return new BetaAnalyticsDataClient([
'credentials' => config('services.ga4.credentials'),
]);
});
Wrap it in a report class, not in controllers. Controllers should never see protobuf objects.
class AnalyticsReporter
{
public function __construct(private BetaAnalyticsDataClient $client) {}
public function topChannels(int $days = 7): array
{
$response = $this->client->runReport(/* ... */);
return collect($response->getRows())
->map(fn ($row) => [
'channel' => $row->getDimensionValues()[0]->getValue(),
'sessions' => (int) $row->getMetricValues()[0]->getValue(),
])
->all();
}
}
Note the explicit casts. Every dimension and metric value is a protobuf string.
Cache aggressively, keyed on the request payload. GA4 data is not real-time (except the 30-minute realtime endpoint), and quota is per-property-per-hour. A dashboard hitting the API on every page load will get RESOURCE_EXHAUSTED before lunch.
$key = 'ga4:'.md5(serialize([$propertyId, $dimensions, $metrics, $dateRange]));
return Cache::remember($key, now()->addMinutes(15), fn () => $this->reporter->topChannels());
Use short TTLs (5–15 min) for dashboard tiles, long TTLs (6–24 h) for anything keyed to a closed date range — yesterday's data doesn't change.
Build a scheduled warmer. A php artisan analytics:warm command on a 10-minute schedule writing to cache is far more robust than users hitting the API directly. It also gives you one place to log quota errors and alert.
Pagination. One RunReportRequest returns at most 250,000 rows. $response->getRowCount() is the total matching rows, not rows returned. If you need everything:
$offset = 0;
do {
$request->setOffset($offset)->setLimit(10000);
$page = $this->client->runReport($request);
// ... consume $page->getRows()
$offset += count($page->getRows());
} while ($offset < $page->getRowCount() && $page->getRows());
Be aware totals/maximums/minimums are per-page unless you request aggregations separately.
Batch when you have several small reports. batchRunReports() accepts up to 5 requests in one call — ideal for a dashboard with four tiles, cutting round trips 5×.
Filters are trees, not strings. Use FilterExpression with setAndGroup() / setOrGroup() / setNotExpression() / setFilter() and Filter with setFieldName(), setStringFilter() / setInListFilter() / setNumericFilter(). inListFilter is the right tool for "only these 12 campaign names" and is much cheaper than 12 OR'd string filters.
Ordering + limit = top-N. There is no "other" bucketing in the API (that's a UI feature). To mimic top-10-by-sessions, set setOrderBys() on the metric descending, then setLimit(10).
Realtime is a separate call. runRealtimeReport() covers the last 30 minutes, has its own (smaller) dimension/metric set, and no date ranges. Keep it out of your historical report pipeline.
Cohorts and comparisons (Cohort, CohortsRange, Comparison on the request) are supported — use them instead of issuing two near-identical requests for month-over-month deltas.
Testing: don't mock the client. Mock your AnalyticsReporter in feature tests, and in unit tests for the reporter itself pass a stubbed transport via clientOptions['transportConfig'] or inject a fake BetaAnalyticsDataClient subclass. Assert on the array your wrapper returns, not on protobuf shapes.
Two clients, near-identical names. Google\Analytics\Data\V1beta\BetaAnalyticsDataClient is the old, deprecated surface; Google\Analytics\Data\V1beta\Client\BetaAnalyticsDataClient is the current one. The README sample uses the Client\ namespace — copy that, and grep your codebase for the non-Client\ import; it silently still works but is frozen.
Everything is a string. getValue() returns "1234", "", or "(other)" for metrics that had no value in the row. (int) on "" is 0, which is plausible but wrong for rates and averages. Cast float metrics explicitly and treat empty as null:
$value = $row->getMetricValues()[1]->getValue();
$rate = $value === '' ? null : (float) $value;
Dates are YYYYMMDD. The date dimension gives 20240115, not 2024-01-15. dateHour gives 2024011514. Format before display or you'll ship broken charts.
Dates are in the property's timezone, not config('app.timezone'). A "yesterday" range means yesterday for the property. If your app timezone differs from the GA4 property setting, your daily rollups will be off by hours and your tests will pass while dashboards look wrong.
keepEmptyRows defaults to false. Zero-traffic dates and zero-conversion channels simply disappear from the response. If you're plotting a time series, set setKeepEmptyRows(true) or fill gaps client-side — otherwise your line chart lies.
(other) and (not set) are literal strings and will show up in channel/landing-page dimensions. Filter or label them; don't let them leak into user-facing labels.
Quota is token-based and per-property. Standard properties get roughly 40k tokens/hour with ~10 concurrent requests; 360 properties are higher. Symptoms: ApiException with code 8 / RESOURCE_EXHAUSTED. Default retry logic will back off, but inside an HTTP request that means a 30-second page load — that's your signal to move the call to a queue, not to increase the timeout.
A wide, high-cardinality report will OOM PHP. pagePath + fullPageUrl + eventName at 100k rows is hundreds of MB of protobuf objects. Always set a limit, order by the metric you care about, and stream to CSV in chunks rather than materialising the whole response.
Incompatible dimension/metric errors are cryptic: INVALID_ARGUMENT: The dimensions and metrics in the request are incompatible. The message does not tell you which pair. Bisect by calling checkCompatibility() with your two lists — it returns per-pair reasons and will save you an hour.
The credentials client option is deprecated in recent 0.x releases. Prefer ADC (env var + absolute path) so the same code works in local, queue workers, and containers. If you must inline JSON, base64-decode it into an array and pass it — but be aware the option may disappear in a future minor. Note that relative credential paths break under queue workers whose cwd differs; always base_path() / storage_path().
This is a 0.x beta package. v0.22.0 shipped breaking changes to the client surface, and resource types get added/removed between minors (audience list methods were removed from v1alpha in v0.24.0). Pin it — "google/analytics-data": "~0.28.0" — and read the release notes before bumping. Don't use ^0.28.
Currency is not handled. Monetary metrics come back in the property's configured currency, and the API does no conversion. If you're blending properties with different currencies into one number, that number is meaningless.
Use the generated resource-name helper rather than string-concatenating: BetaAnalyticsDataClient::propertyName($id) produces properties/123. It also normalises, so accept either the raw numeric ID or the prefixed form at your config boundary and stop worrying about double-prefixing.
Debugging: catch ApiException and log $e->getStatus() (gRPC status code), $e->getBasicMessage(), and $e->getMetadata() — the metadata often carries the real reason. $response->serializeToJsonString() is the fastest way to dump a whole response shape when a report returns something unexpected.
Extension point: if you need custom caching, retry, or per-tenant credential routing, do it in your wrapper around the client, not by subclassing it. The generated client's only supported customisation hooks are the constructor's clientOptions (apiEndpoint, transport, transportConfig, credentials, credentialsFetcher) — everything else will be overwritten on regeneration.
How can I help you explore Laravel packages today?