To read your own GA4 metrics from inside WordPress (no JavaScript, no embed, just server-side PHP), you authenticate as a Google Cloud service account and call the GA4 Data API. The whole job is one runReport against your property ID. Here is the wrapper that fetches your top pages by views for the last 28 days:
use Google\Analytics\Data\V1beta\BetaAnalyticsDataClient;
use Google\Analytics\Data\V1beta\DateRange;
use Google\Analytics\Data\V1beta\Dimension;
use Google\Analytics\Data\V1beta\Metric;
use Google\Analytics\Data\V1beta\OrderBy;
function te_ga4_run_report( $property_id ) {
$client = new BetaAnalyticsDataClient();
return $client->runReport( [
'property' => 'properties/' . $property_id,
'dateRanges' => [
new DateRange( [ 'start_date' => '28daysAgo', 'end_date' => 'today' ] ),
],
'dimensions' => [ new Dimension( [ 'name' => 'pagePath' ] ) ],
'metrics' => [ new Metric( [ 'name' => 'screenPageViews' ] ) ],
'orderBys' => [
new OrderBy( [
'metric' => new OrderBy\MetricOrderBy( [ 'metric_name' => 'screenPageViews' ] ),
'desc' => true,
] ),
],
'limit' => 10,
] );
}That returns a RunReportResponse with one row per page path. The rest of this article is the setup that makes the line new BetaAnalyticsDataClient() actually authenticate, and two things you must not skip: caching the result so you do not hit the API on every page load, and keeping the credentials file out of the web root and out of git.
One note before the setup. The old analytics/v3 (Universal Analytics) API is gone: Universal Analytics stopped processing new data on 1 July 2023, and Google deleted the historical data a year later. GA4 and its Data API (analyticsdata.googleapis.com) are the only option now, so if you are following an older tutorial that talks about Google_Service_Analytics and view IDs, throw it out.
Step 1: the Google Cloud service account
A service account is a non-human Google identity your server authenticates as. You create it once, download a JSON key, and grant it read access to the GA4 property. Three sub-steps, two different consoles.
In the Google Cloud console (console.cloud.google.com):
- Pick or create a project.
- Enable the Google Analytics Data API under "APIs & Services" → "Library". Search for it by name and click Enable. Nothing works until this is on.
- Under "IAM & Admin" → "Service Accounts", create a service account. Name it something honest like
wp-ga4-reader. You do not need to grant it any project-level IAM role for this; its access comes from the GA4 property grant in step 3, not from a Cloud role. - Open the new service account, go to the "Keys" tab, "Add Key" → "Create new key" → JSON. The browser downloads a
.jsonfile. This is the only copy of the private key, and Google will not show it to you again. Treat it like a password.
Open that JSON once in a text editor and note the client_email value. It looks like wp-ga4-reader@your-project.iam.gserviceaccount.com. You need it for the next step.
Step 2: grant the service account access to the GA4 property
This is the step people miss, and the symptom is a 403 PERMISSION_DENIED that has nothing to do with your code. The service account can hold a valid key and still see nothing, because API access and property access are separate grants. The key proves who it is; the property grant decides what it can read.
In the Google Analytics admin (the analytics UI, not the Cloud console):
- Admin → Property → "Property access management".
- Add a user. Paste the service account's
client_emailaddress (the...iam.gserviceaccount.comone). - Give it the Viewer role. Reporting is read-only, so Viewer is exactly enough; do not hand a server-side reader Editor or Administrator.
While you are in Admin, grab the property ID: Admin → Property → "Property details", or it is the numeric value in the property settings (a number like 123456789, not the G-XXXXXXXXXX measurement ID, which is a different thing). That number is what you pass to te_ga4_run_report().
Step 3: install the client with Composer
The GA4 Data API PHP client is published as google/analytics-data on Packager. From the directory where you keep your plugin or theme's dependencies:
composer require google/analytics-dataThat pulls in the generated GA4 client plus the gRPC/REST transport and the Google auth library. Then load Composer's autoloader once, early, before you call any Google\... class:
require_once __DIR__ . '/vendor/autoload.php';If you are bundling this inside a plugin, that require_once goes in your main plugin file. A quick way to confirm the install took: composer show google/analytics-data prints the installed version.
Step 4: point the client at the JSON key
new BetaAnalyticsDataClient() with no arguments uses Application Default Credentials, which means it reads the path in the GOOGLE_APPLICATION_CREDENTIALS environment variable and authenticates as that service account. The cleanest way to set it is at the server level (an Apache SetEnv, an Nginx/PHP-FPM env directive, or your container's environment), pointing at the key file you placed outside the web root:
GOOGLE_APPLICATION_CREDENTIALS="/etc/secrets/wp-ga4-reader.json"If you cannot set a server env var (shared hosting, for instance), pass the path explicitly when you build the client instead:
function te_ga4_client( $key_path ) {
return new BetaAnalyticsDataClient( [
'credentials' => $key_path,
] );
}Either way the rule is the same: the JSON file lives somewhere PHP can read but the web server will never serve. I come back to that in the security section, because it is the part that bites people.
Step 5: cache the result, do not call the API on every request
The GA4 Data API has per-property quotas, and a runReport round trip adds real latency (think hundreds of milliseconds, sometimes more) to whatever request triggers it. Calling it inside a template or a widget that renders on every page load is how you blow your quota and slow the whole site down for one "popular posts" box.
So cache it. A WordPress transient with an hour TTL is the right tool: read from cache, and only hit the API on a miss.
function te_ga4_top_pages( $property_id ) {
$cached = get_transient( 'te_ga4_top_pages' );
if ( false !== $cached ) {
return $cached;
}
$pages = [];
$response = te_ga4_run_report( $property_id );
foreach ( $response->getRows() as $row ) {
$pages[] = [
'path' => $row->getDimensionValues()[0]->getValue(),
'views' => (int) $row->getMetricValues()[0]->getValue(),
];
}
set_transient( 'te_ga4_top_pages', $pages, HOUR_IN_SECONDS );
return $pages;
}Now the API gets hit at most once an hour, and every page load after the first reads a small array out of the options table (or object cache). The view template just loops over the returned array:
$pages = te_ga4_top_pages( '123456789' );
foreach ( $pages as $page ) {
printf(
'<li><a href="%1$s">%1$s</a> — %2$d views</li>',
esc_html( $page['path'] ),
$page['views']
);
}A small "top pages" list, fed entirely from your own analytics, with no client-side tracking script involved.
Better: refresh it on a schedule, not on a cache miss
Tying the API call to a cache miss means whichever unlucky visitor lands right after the transient expires eats the full API latency. The cleaner pattern is to refresh the cache out of band on a WP-Cron schedule, so the front end only ever reads a warm cache and never blocks on Google:
add_action( 'te_ga4_refresh_event', function () {
delete_transient( 'te_ga4_top_pages' );
te_ga4_top_pages( '123456789' );
} );
if ( ! wp_next_scheduled( 'te_ga4_refresh_event' ) ) {
wp_schedule_event( time(), 'hourly', 'te_ga4_refresh_event' );
}That is a standard recurring task. If the cron mechanics are new to you (WP-Cron firing on page loads, registering custom intervals, why wp_schedule_event needs the guard above), I wrote them up in scheduling a recurring task with WP-Cron. For sites with real traffic, swap WP-Cron for a real system cron hitting wp-cron.php, so the refresh fires on time regardless of whether anyone is browsing.
Step 6: credential security (the part you cannot skip)
That JSON key is a private key. Anyone who reads it can authenticate as your service account and pull your analytics, and if you ever grant the account more than Viewer, do more. Two non-negotiable rules:
Keep the key file outside the web root. If it sits in wp-content/ or anywhere under your document root, a single misconfiguration (a directory listing, a .json served as plain text, a backup plugin zipping wp-content) leaks the key. Put it somewhere PHP can read but Apache or Nginx will never map to a URL, for example /etc/secrets/ or one level above public_html. Lock the permissions down too: chmod 600 so only the PHP user can read it.
Never commit it to git. This is how most service-account keys leak, in a public or even private repo. Add the key (and the whole secrets directory) to .gitignore before the first commit:
# never commit service-account keys
*.json.key
/secrets/
wp-ga4-reader.jsonIf a key ever does land in a repo, do not just delete the file in a new commit. It is still in history. Treat it as compromised: in the Cloud console, delete that key from the service account (which immediately invalidates it) and create a fresh one. Rotating the key is a two-minute job; cleaning a leaked credential out of every clone and fork is not.
The whole thing as a small plugin
Wrapping it as a must-use or regular plugin keeps the Composer autoload, the property ID, and the functions in one auditable place that survives theme switches:
<?php
/**
* Plugin Name: TE GA4 Report
* Plugin URI: https://techearl.com/wordpress-pull-ga4-data-service-account
* Description: Reads top pages from the GA4 Data API server-side via a service account, cached in a transient and refreshed on WP-Cron.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-ga4-report
*/
defined( 'ABSPATH' ) || exit;
require_once __DIR__ . '/vendor/autoload.php';The functions above (te_ga4_run_report(), te_ga4_top_pages(), the cron callback) live below that header. The property ID and the key path are the only two values you change per site; pull them from constants in wp-config.php rather than hard-coding them in the plugin, so the same plugin file ships unchanged across environments.
Verify it worked
- Auth works: call
te_ga4_run_report( 'YOUR_PROPERTY_ID' )from a throwaway WP-CLI snippet (wp eval-file test.php) andvar_dump( $response->getRowCount() ). A non-zero count means the key, the API enablement, and the property grant all line up. - A 403 means the property grant, not the code. If you get
PERMISSION_DENIED, re-check step 2: the service account'sclient_emailmust be listed under the property's access management with at least Viewer. A valid key with no property grant fails exactly here. - A 7 /
UNAUTHENTICATEDor "could not load default credentials" means the client never found the key: theGOOGLE_APPLICATION_CREDENTIALSpath is wrong, the env var did not reach PHP-FPM, or the file is not readable by the PHP user. - Cache behaves: load the page twice and confirm only the first call is slow. Then check
wp_optionsfor the_transient_te_ga4_top_pagesrow, andwp cron event listto confirmte_ga4_refresh_eventis scheduled.
See also
- Schedule a recurring task with WP-Cron: the cron mechanics behind refreshing the GA4 cache out of band, including custom intervals and why WP-Cron alone is unreliable on low-traffic sites
- Clean up wp_head in WordPress: the companion housekeeping pass once you are reading analytics server-side and can drop the front-end clutter
- An AI-assisted WP-CLI workflow: how I drive one-off WordPress tasks like the
wp eval-fileverification snippet above from the command line - How to optimize WooCommerce: where caching discipline like the transient pattern here pays off most, on a store under real load
Sources
Authoritative references this article was fact-checked against.
- Google Analytics Data API (GA4) Overview: Google for Developersdevelopers.google.com
- google/analytics-data: GA4 Data API PHP client (GitHub)github.com
- Service accounts overview: Google Cloud documentationcloud.google.com





