TechEarl

Pull GA4 Data Into WordPress With a Service Account

Read your own GA4 metrics server-side from WordPress: a Google Cloud service account, the google/analytics-data PHP client, a te_ga4_run_report() wrapper around runReport, plus caching and credential hygiene.

Ishan Karunaratne⏱️ 11 min readUpdated
Share thisCopied
How to pull GA4 (Google Analytics 4) data into WordPress server-side with a Google Cloud service account and the google/analytics-data PHP client: a runReport call for top pages, cached in a transient and scheduled with WP-Cron.

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:

php
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):

  1. Pick or create a project.
  2. Enable the Google Analytics Data API under "APIs & Services" → "Library". Search for it by name and click Enable. Nothing works until this is on.
  3. 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.
  4. Open the new service account, go to the "Keys" tab, "Add Key" → "Create new key" → JSON. The browser downloads a .json file. 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):

  1. Admin → Property → "Property access management".
  2. Add a user. Paste the service account's client_email address (the ...iam.gserviceaccount.com one).
  3. 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:

bash
composer require google/analytics-data

That 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:

php
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:

bash
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:

php
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.

php
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:

php
$pages = te_ga4_top_pages( '123456789' );
foreach ( $pages as $page ) {
    printf(
        '<li><a href="%1$s">%1$s</a> &mdash; %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:

php
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:

text
# never commit service-account keys
*.json.key
/secrets/
wp-ga4-reader.json

If 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
<?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

  1. Auth works: call te_ga4_run_report( 'YOUR_PROPERTY_ID' ) from a throwaway WP-CLI snippet (wp eval-file test.php) and var_dump( $response->getRowCount() ). A non-zero count means the key, the API enablement, and the property grant all line up.
  2. A 403 means the property grant, not the code. If you get PERMISSION_DENIED, re-check step 2: the service account's client_email must be listed under the property's access management with at least Viewer. A valid key with no property grant fails exactly here.
  3. A 7 / UNAUTHENTICATED or "could not load default credentials" means the client never found the key: the GOOGLE_APPLICATION_CREDENTIALS path is wrong, the env var did not reach PHP-FPM, or the file is not readable by the PHP user.
  4. Cache behaves: load the page twice and confirm only the first call is slow. Then check wp_options for the _transient_te_ga4_top_pages row, and wp cron event list to confirm te_ga4_refresh_event is scheduled.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPHPGA4Google AnalyticsService AccountWP-Cron

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts