google/cloud
Official Google Cloud PHP client library providing idiomatic access to Cloud Storage, BigQuery, Pub/Sub, Datastore, Compute, and other Google Cloud services. Supports REST and gRPC transports, application default credentials, pagination, long-running operations, and emulator support for testing.
Install the component you need, not the umbrella package. google/cloud is the meta-package for the whole monorepo — it drags in Storage, Firestore, BigQuery, Pub/Sub, Vision, Translate, Spanner, and dozens more, plus google/gax, google/protobuf, grpc/grpc, and guzzlehttp/guzzle. In a Laravel app that is a lot of dead weight and a bigger surface for Composer conflicts. Require only what you use:
composer require google/cloud-storage # files, signed URLs
composer require google/cloud-firestore # realtime/append-only data
composer require google/cloud-pubsub # events, fan-out
composer require google/cloud-bigquery # analytics export
composer require google/cloud-translate # i18n
composer require google/cloud-secret-manager # secrets
If you genuinely need three or more services, google/cloud is fine — it just pins you to whatever component versions it requires that day.
Auth first. Create a service account in the target project, grant the narrowest role that works (roles/storage.objectAdmin on one bucket beats roles/editor), download the JSON, and put it outside public/ — storage/app/gcp/service-account.json is a reasonable home. Then:
// config/services.php
'gcp' => [
'project_id' => env('GCP_PROJECT_ID'),
'credentials' => env('GOOGLE_APPLICATION_CREDENTIALS'),
'bucket' => env('GCS_BUCKET'),
],
GCP_PROJECT_ID=my-project
GOOGLE_APPLICATION_CREDENTIALS=/var/www/app/storage/app/gcp/service-account.json
GCS_BUCKET=my-project-uploads
Setting GOOGLE_APPLICATION_CREDENTIALS is the single most important step — it short-circuits Application Default Credentials' lookup chain, which otherwise ends with a call to the GCE metadata server that hangs for a few seconds on your laptop.
First use case: user upload → private object → signed URL. This is the 80% case in a Laravel app.
use Google\Cloud\Storage\StorageClient;
$storage = new StorageClient([
'projectId' => config('services.gcp.project_id'),
'keyFilePath' => config('services.gcp.credentials'),
]);
$bucket = $storage->bucket(config('services.gcp.bucket'));
$object = $bucket->upload(
fopen($request->file('avatar')->getRealPath(), 'r'),
[
'name' => "avatars/{$user->id}.jpg",
'metadata' => ['contentType' => $request->file('avatar')->getMimeType()],
]
);
return ['url' => $object->signedUrl(new \DateTimeImmutable('+15 minutes'))];
Where to look first: the per-component README.md and samples/ directory in the monorepo (e.g. Storage/samples/), then the generated API docs for the client class. The root docs/ folder covers auth, pagination, long-running operations, and error handling generically — those pages apply to every service, so read them once.
Bind clients as singletons in a service provider. Every client builds a Guzzle stack and (optionally) a gRPC channel on construction; constructing one per request in a controller is wasteful.
// app/Providers/GoogleCloudServiceProvider.php
public function register(): void
{
$this->app->singleton(StorageClient::class, fn () => new StorageClient([
'projectId' => config('services.gcp.project_id'),
'keyFilePath' => config('services.gcp.credentials'),
]));
$this->app->singleton(FirestoreClient::class, fn () => new FirestoreClient([
'projectId' => config('services.gcp.project_id'),
'keyFilePath' => config('services.gcp.credentials'),
]));
}
Wrap the raw SDK behind your own small interface (AvatarStorage, EventBus) so tests can swap a fake in. The SDK ships no Laravel facade and no config file — you are the integration layer, and that is a feature: your domain code shouldn't use Google\Cloud\... directly.
Prefer the Flysystem disk for plain file operations. league/flysystem-google-cloud-storage gives you a gcs disk, so Storage::disk('gcs')->put(...), ->temporaryUrl(...), and ->url(...) behave like every other disk and work with Storage::fake() in tests. Reach for the raw StorageClient when you need object metadata, ACLs, resumable uploads, or bucket lifecycle config.
Signed URLs for private assets. $object->signedUrl() (V2) or ['version' => 'v4'] gives time-limited access without proxying bytes through PHP — the reason to use GCS instead of local disk in the first place. Keep expiry short (5–15 min) and generate per request; do not cache signed URLs in your CDN config.
Pub/Sub for cross-service events, not for your job queue. Laravel's queue is better for retries, backoff, failed-job tables, and Horizon. Use Pub/Sub when something outside Laravel needs to react (a Cloud Function, a data pipeline), or when you need fan-out to N subscribers. Publishing is trivial and belongs in a queued job so the HTTP request doesn't wait on it:
$pubsub = new PubSubClient(['projectId' => config('services.gcp.project_id')]);
$pubsub->topic('user-registered')->publish([
'data' => json_encode(['id' => $user->id, 'at' => now()->toIso8601String()]),
'attributes' => ['version' => '1'],
]);
Long-running operations belong in jobs. $operation->pollUntilComplete() will burn a PHP-FPM worker for minutes. Anything that returns a LongRunningOperation (BigQuery loads, Dataproc, some Vision batch jobs) should be kicked off in a job and polled in a follow-up job or a scheduled command.
BigQuery for analytics, never on the request path. Buffer events in a table or Redis and flush on a schedule. insertRows() is a streaming insert (per-row cost, rows visible immediately, but not deletable for ~90 minutes); for bulk backfills use a GCS load job instead — far cheaper.
Firestore is not Eloquent. No joins, no LIKE, no schema, and pricing is per document read. It shines for realtime listeners, append-only event logs, and mobile-sync data. If your data is relational, keep it in MySQL and export to BigQuery for reporting. When you do need atomicity, use a transaction:
$db->runTransaction(function (Transaction $tx) use ($orderId, $delta) {
$ref = $tx->document("counters/{$orderId}");
$snap = $tx->snapshot($ref);
$tx->set($ref, ['count' => ($snap->value('count') ?? 0) + $delta]);
});
Secret Manager with a config-cache caveat. Fetching a secret at boot is fine, but once you run php artisan config:cache, .env is not loaded and env() returns null everywhere. Resolve secrets in a service provider using config values, or fetch them lazily inside the service that needs them and memoize per process.
Testing against emulators. Point the SDK at local emulators instead of mocking internals:
FIRESTORE_EMULATOR_HOST=localhost:8080
PUBSUB_EMULATOR_HOST=localhost:8085
STORAGE_EMULATOR_HOST=http://localhost:4443
The clients detect these automatically. A docker-compose with the google/cloud-sdk image and gcloud beta emulators ... start makes integration tests fast and free.
google/cloud pins component versions. If you need a newer google/cloud-storage than the meta-package allows, require the component explicitly — Composer will resolve it, and you can drop the meta-package entirely. composer why google/cloud-core tells you who is holding you back.GOOGLE_APPLICATION_CREDENTIALS is unset and you're not on GCP, ADC's final fallback tries 169.254.169.254 and blocks. Symptom: first request takes 2–5 seconds, subsequent ones are fine in long-running workers but every fresh CLI command pays it. Always set the env var locally and in CI.env() outside config/. After config:cache, env() returns null. This bites people who put env('GCS_BUCKET') in a service provider. Put every GCP value in config/services.php and read it with config().ServiceException subclasses. Catch Google\Cloud\Core\Exception\NotFoundException, BadRequestException, ConflictException, ServiceException — in that order, most specific first. They all extend ServiceException, which extends \Exception, so a bare catch (\Exception) will swallow them silently. Log $e->getMessage() and the error details payload; the message alone often omits the field that failed validation.expires you passed as int seconds when the SDK expected a DateTimeInterface. Use new \DateTimeImmutable('+15 minutes').signedUrl() falls back to the IAM signBlob API. The runtime service account needs roles/iam.serviceAccountTokenCreator on itself. Forgetting this yields a confusing 403 at URL-generation time, not at download time.$object->acl()->add(...) throws. Manage access with IAM, or make objects private and serve via signed URLs.$bucket->objects() returns a lazy iterator; iterator_to_array() or ->rows() on a large result set will exhaust memory. Use foreach ($bucket->objects() as $object) or ->iterateAll() and always set ['maxResults' => ...] / ['pageSize' => ...] when you only need the first page.grpc and protobuf PHP extensions the SDK falls back to REST — correct, but noticeably slower for chatty workloads like Firestore and Pub/Sub. Install the extensions in your Docker image; if you can't, pass ['transport' => 'rest'] explicitly so you're not relying on autodetection.['requestTimeout' => 5.0] (or retries => 0 for non-idempotent writes) so failures surface as exceptions you can handle rather than 30-second hangs.SELECT * in a loop; partition and cluster. Storage bills egress — put a CDN in front of public assets and use signed URLs for private ones rather than streaming through Laravel.new AnonymousCredentials() from Google\Cloud\Core rather than passing a key file you don't need.httpOptions (headers, timeout, proxy), credentialsFetcher (custom token refresh), and middleware for Guzzle. The ServiceBuilder class lets you share one credential set across several clients — useful when you build a small GoogleCloud wrapper in your container.composer.lock, and read the component's CHANGELOG.md before bumping — the top-level google/cloud changelog is a merge of all of them and is hard to scan.How can I help you explore Laravel packages today?