google/analytics-admin
Official Google PHP client for the Analytics Admin API, enabling developers to programmatically manage Google Analytics 4 accounts, properties, data streams, audiences, and related configuration from PHP or Laravel applications.
google/analytics-admin is the GAPIC-generated PHP client for the Google Analytics Admin API — the control plane for GA4. It manages accounts, properties, data streams, custom dimensions, conversion events, audiences, links (Ads/Firebase/BigQuery), measurement-protocol secrets, access bindings, and data-retention settings. It does not report on traffic; use google/analytics-data for that (runAccessReport is the one reporting exception that lives here).
Minimal setup
composer require google/analytics-admin (requires PHP 8.1+ on current releases).analyticsadmin.googleapis.com, create a service account, download the JSON key.PERMISSION_DENIED even with a valid token — this is the single most common first-run failure.// config/services.php
'analytics' => [
'credentials' => env('GOOGLE_APPLICATION_CREDENTIALS'), // path or JSON array
'account_id' => env('GA_ACCOUNT_ID'),
],
// AppServiceProvider::register()
use Google\Analytics\Admin\V1beta\Client\AnalyticsAdminServiceClient;
$this->app->singleton(AnalyticsAdminServiceClient::class, fn () => new AnalyticsAdminServiceClient([
'credentials' => config('services.analytics.credentials'),
'transport' => 'rest', // avoids needing ext-grpc; fine for admin traffic
'credentialsConfig' => [
'scopes' => ['https://www.googleapis.com/auth/analytics.edit'],
],
]));
First use case — list what you can reach, then create a web stream and grab its measurement ID:
$client = app(AnalyticsAdminServiceClient::class);
foreach ($client->listAccountSummaries() as $summary) {
dump($summary->getDisplayName(), $summary->getPropertySummaries());
}
$stream = (new DataStream())
->setType(DataStream\DataStreamType::WEB)
->setDisplayName('Website')
->setWebStreamData(
(new DataStream\WebStreamData())->setDefaultUri('https://acme.test')
);
$created = $client->createDataStream('properties/123456789', $stream);
$measurementId = $created->getWebStreamData()->getMeasurementId(); // G-XXXXXXX
Wrap the client behind your own interface. The GAPIC client is huge and auto-generated; don't inject it into controllers. Define a narrow contract (AnalyticsProvisioner with createProperty, ensureConversionEvent, measurementIdFor, …) and bind the real implementation to the GAPIC client. This is also what makes the code testable.
final class GoogleAnalyticsProvisioner implements AnalyticsProvisioner
{
public function __construct(private AnalyticsAdminServiceClient $client) {}
}
Tenant provisioning as a queued job. Creating a property + data stream + measurement ID is a multi-step, quota-limited, partially-consistent operation — never do it inline in a web request. Push a ProvisionAnalyticsProperty job with $tries = 5 and backoff(), persist the properties/… resource name as soon as it exists so retries are idempotent (check for an existing property before creating).
Declarative sync for dimensions and events. There is no upsert. The reliable pattern is list → diff → create/update/archive, driven by a PHP config array that mirrors your product's analytics contract:
$existing = collect($client->listCustomDimensions($propertyName))
->keyBy(fn ($d) => $d->getParameterName());
foreach (config('analytics.dimensions') as $dim) {
if ($existing->has($dim['parameter_name'])) {
continue;
}
$client->createCustomDimension($propertyName, (new CustomDimension())
->setParameterName($dim['parameter_name'])
->setDisplayName($dim['display_name'])
->setScope($dim['scope']));
}
Run this from a scheduled command so drift is corrected automatically.
Updates always need a FieldMask. Send only the fields you mutate:
$property = (new Property())
->setName('properties/123456789')
->setDisplayName('Acme — Prod');
$client->updateProperty($property, new FieldMask(['paths' => ['display_name']]));
Cache the read side aggressively. Account/property/stream listings change rarely. Cache::remember("ga:properties:{$accountId}", 3600, fn () => ...) and bust the key after writes. listAccountSummaries() is a single cheap call that returns accounts and properties — prefer it over nested listAccounts + listProperties loops.
Pagination. Every list* returns a PagedListResponse; iterate directly or tune the page:
$paged = $client->listProperties(['pageSize' => 200, 'filter' => 'parent:accounts/123']);
foreach ($paged->iterateAllElements() as $property) { /* ... */ }
Getting the measurement ID server-side. createMeasurementProtocolSecret() on a web stream gives you a secret for sending Measurement Protocol events from Laravel (server-side purchases, refunds) without exposing anything to the browser. Pair the stream's measurementId with the secret and store both per tenant.
Data retention & consent. updateDataRetentionSettings() (14 vs 2 months) and acknowledgeUserDataCollection() are one-liners you should call at provisioning time rather than leaving to manual UI work.
Account creation is a redirect flow. You cannot createAccount(). Use provisionAccountTicket() to get a redirectUri, send the customer there to accept the GA4 terms, and reconcile the resulting account via a webhook/callback or polling.
Testing. Either mock your own interface, or stub the wire layer — GAPIC ships Google\ApiCore\Testing\MockTransport, which you pass as 'transport' => $mockTransport to exercise real serialization without network calls.
google/analytics-data alongside this.list* calls or accept dimension creation for a short window. Retry with backoff in provisioning jobs; don't assume read-after-write.accounts/123, properties/456, properties/456/dataStreams/789. Methods take the string name, not a numeric ID. Extract with Str::after($name, 'properties/') or basename($name).deletable is false for GA4-native events). Design sync logic around create+update, not delete, wherever possible.FieldMask is not "update everything" in a useful way — it can be rejected or silently no-op. Always enumerate the paths you intend to change.getDisplayName(), getName()). For logging, use $msg->serializeToJsonString() rather than json_encode() on nested structures.V1beta is where most production features live; V1alpha has newer resources but less stability. Mixing the two clients produces confusing resource-name and enum mismatches — the enums are not interchangeable across namespaces.Google\ApiCore\ApiException, branch on $e->getStatus() (NOT_FOUND, PERMISSION_DENIED, INVALID_ARGUMENT, RESOURCE_EXHAUSTED for quota), and report() the rest. Quota errors should back off, not fail the job.retrySettings on the client or per-call $optionalArgs — useful for createProperty. Don't wrap non-idempotent creates in blind retries without an existence check.rest keeps your Docker image free of ext-grpc and works fine for admin call volumes; switch to grpc only if you have a measured latency problem.analytics.readonly for any code path that only reads. A single client with analytics.edit everywhere will eventually do something destructive in a staging script.*Client.php method signatures, since they are the source of truth for argument order.How can I help you explore Laravel packages today?