hprotravel/php-gcp-rest-client
A lightweight PHP client for Google Cloud Platform REST APIs, giving Laravel and PHP applications a simple, dependency-friendly way to authenticate and call GCP services over HTTP without pulling in the full official SDK.
This is a thin, private Guzzle adapter — not a vendor SDK. Treat it as internal glue you own. The first hour should be spent reading src/ rather than looking for docs, because there are none and the whole library is small enough to read end to end. Start at the client constructor and the token-acquisition path; that is where nearly all of the behavior you care about lives.
1. Install from the private Bitbucket repo. It is not on Packagist, so add a VCS repository and confirm CI runners have an SSH deploy key or a Bitbucket app password before you debug a hanging composer install:
{
"repositories": [
{ "type": "vcs", "url": "[email protected]:hprotravel/php-gcp-rest-client.git" }
],
"require": {
"hprotravel/php-gcp-rest-client": "^1.0"
}
}
Pin to a tag or commit SHA. dev-master against an internal repo means every build silently drifts.
2. Get a service account key. You need a GCP service account JSON with the IAM roles for whatever you're calling (e.g. roles/storage.objectViewer for GCS reads, roles/secretmanager.secretAccessor for Secret Manager). Store the path or the JSON itself via env/config — never in the repo and never in a committed .env.example. In Laravel, put it in config/services.php and read from there so tests can override it:
// config/services.php
'gcp' => [
'project_id' => env('GCP_PROJECT_ID'),
'credentials_path' => env('GCP_CREDENTIALS_PATH'),
'scopes' => ['https://www.googleapis.com/auth/cloud-platform'],
'token_cache' => storage_path('app/gcp-token.json'),
],
3. First call — pick a read-only endpoint. The lowest-risk smoke test is listing GCS buckets or reading one secret. It validates install, credential parsing, scope selection, and the token round-trip in a single request without any write side effects:
$client = app(GcpRestClient::class); // whatever the constructor is named in your fork
$response = $client->get(
"https://storage.googleapis.com/storage/v1/b?project={$projectId}"
);
If that returns JSON, you're 80% of the way to production. Everything after that is ergonomics.
First real use case. Wrap it in a service provider, bind it as a singleton, and expose intent-named interfaces rather than the raw client. The first concrete one should be the narrowest thing your app actually needs — typically ObjectStorageGateway for uploads/downloads or SecretReader for config injection. Controllers and jobs should never see GcpRestClient directly; that boundary is what makes the eventual swap to google/cloud-* a contained change.
Bind behind intent interfaces, not HTTP verbs. The package exposes get/post/put/delete, which is too low-level for domain code. Define interfaces like PublishToTopic, ReadSecret, UploadObject and implement each with one or two calls to the client. The HTTP verb belongs to the adapter, not the caller. This is the single most important pattern — everything below depends on it.
Service provider singleton with explicit config. Register the client once with credentials resolved at boot from config (not env() calls scattered through the app, which break under config:cache):
$this->app->singleton(GcpRestClient::class, function ($app) {
$cfg = $app['config']['services.gcp'];
return new GcpRestClient(
credentialsPath: $cfg['credentials_path'],
projectId: $cfg['project_id'],
scopes: $cfg['scopes'],
tokenCachePath: $cfg['token_cache'],
);
});
Token caching — understand what "automatic" actually means. The package advertises automatic OAuth tokens with caching, but the cache is file-based or in-memory (read the source to confirm which). File-based caches work across requests per-process, but on multi-worker setups (Octane, queue workers, Lambda) each process refreshes independently. For anything above trivial volume, wrap the client with a cache backed by your shared store (Cache::remember('gcp_token', 3300, ...)) or accept the refresh storm risk on deploy.
Job and queue integration. For background work, resolve the interface via the container inside the job's handle() — don't inject the client into the constructor and serialize it (it holds a Guzzle client and possibly a path to a token cache, both of which are unserializable or stale by the time the job runs). Set an explicit $timeout on the job larger than the HTTP timeout you configure (see gotchas), otherwise a hung socket starves your worker pool.
Retry belongs in your wrapper, not the package. The adapter is a dumb pass-through — it does not implement backoff. Compose it with Laravel's Http::retry — no, that's a different client. Instead, add retry at the interface implementation layer, using Guzzle's RetryMiddleware on the underlying handler or wrapping your own interface methods with a jittered-backoff loop on 429/503. This is also where you add structured logging (project ID, endpoint, status) so you can debug from logs instead of from a stack trace at line 40 of a Guzzle handler stack.
Pattern: multi-endpoint fan-out. When a request needs data from two GCP services (e.g., a secret + an object), don't chain blocking calls in a controller. Wrap each in its own intent interface, dispatch as queued jobs or Http::pool-style parallel calls at the Guzzle layer, and reassemble. Guzzle is blocking, so true parallelism only comes from Guzzle's async promise interface, which you'd need to expose yourself.
Testing pattern. Because it's a Guzzle client, you can inject a MockHandler or a custom handler stack in tests. Bind a test double for the intent interfaces rather than the raw client — that keeps tests independent of GCP request shapes and survives a swap to google/cloud-* later. Add one live contract test (nightly, not per-PR) against a sandbox project to catch GCP REST drift before production does.
Config quirks to encode in the provider. Scopes default to cloud-platform if unset — fine for a spike, wrong for production (least privilege). Project ID is only needed for project-scoped endpoints (GCS, Pub/Sub); service-scoped ones (Secret Manager) ignore it but still accept it. Make both explicit in config so reviewers can see intent.
It's on a private Bitbucket repo — builds fail when access does. Losing repo credentials, a rotated app password, or a Bitbucket outage breaks CI, not just deploys. Mirror the repo into your own workspace (or fork it in-house) and point dependency resolution at the mirror. Record the upstream commit SHA in a comment and treat the fork as the source of truth you actually own.
Auth is the sharp edge. The package does not solve credential rotation, workload identity federation, or impersonation. It solves "sign a JWT from a JSON key and get a token." Consequences:
scopes in config.supervisord/Octane restart can produce a synchronized refresh spike; add jitter to any wrapper refresh logic.Authorization headers if you enable request logging.Set timeouts everywhere. Guzzle's default is 0 (infinite) on connect in some configurations, and the package does not override it. A hung GCP request becomes a hung HTTP request becomes a hung queue worker. Set timeout and connect_timeout explicitly on the handler stack, and set the Laravel job $timeout above them. This is the most common production failure mode with thin adapters.
Parse GCP error envelopes, don't just $response->getBody(). GCP returns errors as {"error": {"code": ..., "message": ..., "status": ...}} with a useful HTTP status and a structured body. The package likely throws GuzzleHttp\Exception\RequestException without unwrapping the body. Wrap each intent method to catch, parse the envelope, and throw a domain exception with google_error.code and google_error.message surfaced. Otherwise your alerts say "500" and your on-call reads JSON by hand.
Retries on 429/503 need idempotency. Blind retry middleware on a POST to Pub/Sub or a GCS upload can double-publish. Only retry methods that are idempotent by design (GET, PUT with a content hash, DELETE), or use the endpoint's own idempotency key. This is a wrapper-layer decision, not a package one.
Guzzle version alignment. Laravel 9+ pulls Guzzle 7. A Guzzle 6 pin inside this package (or an old guzzlehttp/psr7 constraint) will conflict in the lockfile and can downgrade the framework's own illuminate/http chain. Before adopting, run composer why-not guzzlehttp/guzzle ^7.0 and composer update --dry-run in a spike branch to confirm no downgrades. If it pins an old psr/http-message, stop and patch before use.
PHP version floor. Check composer.json's php constraint against your runtime. If it says ^7.4 and you're on 8.2, it works, but you won't get any deprecation fixes upstream — expect to patch it yourself.
Token cache file path. If the cache lives at a path like storage/app/gcp-token.json, make sure it's writable by the web user and by queue workers (which often run as a different UID). A read-only filesystem (Lambda, some container runtimes) makes file caching fail silently or throw at write time. In that case, disable file caching and use your app cache.
Missing features you'll implement. None of the following come out of the box, and you will hit all of them within a month:
nextPageToken) — loop in the wrapper, not at call sites.operations/{id} polling for Vertex AI, Dataproc, etc.) — build a pollUntilDone helper.Budget them into the sprint after the spike, or your "lightweight adapter" quietly becomes a custom SDK.
Extension points that actually exist. Because it's a Guzzle wrapper, the honest extension story is: subclass or fork and inject a custom HandlerStack with your middleware. Don't try to extend through config. If the constructor doesn't accept a handler, patch the fork — that's the correct move given you own it anyway. Keep patches isolated in a patches/ directory or a small internal fork so upgrades (if any ever come) are tractable.
Deprecations are silent. Nothing upstream alerts you when Google deprecates a REST field. Add a nightly contract test that calls one representative endpoint per supported surface, and log the X-Goog-* deprecation headers if present. Without this, the first sign of drift is a failed job in production at 2am.
The swap-out path is the exit strategy. Because intent interfaces are the boundary, replacing this package with google/cloud-* (or plain Guzzle plus google/auth) is a matter of rewriting the implementation classes, not the call sites. Set an explicit review date — one to two quarters after adoption — to reassess, and measure the actual dependency delta versus the official SDK. If the savings turn out to be marginal, the maintenance burden is not worth it, and you'll want that measurement on paper rather than in memory.
How can I help you explore Laravel packages today?