superbalist/laravel-google-cloud-storage
A Laravel filesystem driver for Google Cloud Storage, letting you use GCS as a native storage disk through Flysystem. Configure credentials and bucket settings, then read, write, and delete files with Laravel's familiar Storage API.
Install and wire it up (5 minutes).
composer require superbalist/laravel-google-cloud-storage
The package ships with Laravel auto-discovery (Superbalist\LaravelGoogleCloudStorage\GoogleCloudStorageServiceProvider), so on 5.5+ there's no provider registration step and nothing to publish. Check config/app.php only if you're on an older app or have discovery disabled.
Add a disk in config/filesystems.php:
'gcs' => [
'driver' => 'gcs',
'project_id' => env('GOOGLE_CLOUD_PROJECT_ID'),
'key_file_path' => env('GOOGLE_CLOUD_KEY_FILE'), // absolute path, optional
'bucket' => env('GOOGLE_CLOUD_STORAGE_BUCKET'),
'path_prefix' => env('GOOGLE_CLOUD_STORAGE_PATH_PREFIX', null),
'storage_api_uri' => env('GOOGLE_CLOUD_STORAGE_API_URI', null),
'visibility' => 'private',
'default_acl' => null, // 'publicRead' only if you really need per-object ACLs
],
Credentials — pick one, in this order of preference:
key_file_path pointing at a service-account JSON file outside the repo (resolve with base_path() if you use a relative value, since the SDK resolves relative to the PHP process CWD).key_file as an inline array (useful when injecting from a secret manager).Google\Cloud\Storage\StorageClient falls back to Application Default Credentials. This is the right choice on GCE / GKE / Cloud Run / App Engine where the service account is already attached. project_id is often optional in that case.First use case — verify the pipe before building on it:
php artisan tinker
>>> Storage::disk('gcs')->put('healthcheck.txt', 'ok');
>>> Storage::disk('gcs')->exists('healthcheck.txt');
>>> Storage::disk('gcs')->url('healthcheck.txt');
>>> Storage::disk('gcs')->delete('healthcheck.txt');
If put succeeds but url() returns something that 403s, that's an ACL/visibility problem, not a wiring problem — see Gotchas.
Where to look first: the disk block above, then vendor/superbalist/laravel-google-cloud-storage/src/GoogleCloudStorageServiceProvider.php (shows exactly which config keys are consumed and how the StorageClient is constructed), then vendor/superbalist/flysystem-google-storage/src/GoogleCloudStorageAdapter.php for anything the Laravel facade doesn't obviously cover. Almost every "does X work?" question resolves in one of those two files.
Treat it as just another disk. The single biggest win is that everything routes through Storage::disk('gcs'), so the same code works against Storage::fake() in tests and a local disk in dev.
$path = $request->file('avatar')->storeAs(
"avatars/{$user->id}",
$filename,
'gcs' // or omit and set FILESYSTEM_DRIVER=gcs in prod
);
Visibility: set it at the disk, override per write. The disk-level visibility key is the default; Flysystem v1 passes a per-write config array you can use to override:
Storage::disk('gcs')->putFileAs('exports', $file, 'report.csv', ['visibility' => 'public']);
Use path_prefix for environment or tenant separation instead of littering keys with prod/ or tenant-42/. It's applied at the adapter level, so put('a.txt') becomes {prefix}/a.txt on the bucket while your application code stays ignorant of it. This also makes bucket-per-environment unnecessary in many setups.
Store paths in the database, not URLs. URLs are derived — bucket names, CDNs, and signing keys change between environments. Persist avatars/17/abc.jpg and resolve at render time:
// public object
Storage::disk('gcs')->url($user->avatar_path);
// private object, time-limited
$url = Storage::disk('gcs')->temporaryUrl($user->avatar_path, now()->addMinutes(15));
Signed URL support depends on your exact Laravel + Flysystem combination; if temporaryUrl throws "driver does not support", call the adapter directly:
$adapter = Storage::disk('gcs')->getAdapter();
$url = $adapter->getTemporaryUrl($path, new \DateTime('+15 minutes'));
Confirm the signature against the adapter source in vendor/ — this is the one API surface that has shifted between releases.
Downloads and streaming. Don't buffer large objects through PHP memory:
return response()->streamDownload(function () use ($path) {
$stream = Storage::disk('gcs')->readStream($path);
fpassthru($stream);
fclose($stream);
}, 'report.pdf', ['Content-Type' => Storage::disk('gcs')->mimeType($path)]);
For uploads of anything user-scale (>50 MB), skip the app server entirely: generate a signed PUT URL with google/cloud-storage directly and let the browser write to the bucket. The adapter does not do resumable uploads, and PHP request limits will bite you first.
Metadata and listing. size(), mimeType(), and lastModified() are the safe, Laravel-standard calls. allFiles() / allDirectories() recurse across the whole prefix and cost one list request per 1000 objects — for anything with a large object count, list with an explicit prefix and paginate, or keep an index table in your database.
Wrapping it for the eventual migration. Because this package is archived and Flysystem-1-only, put a thin interface in front of the operations you actually use (store, url, temporaryUrl, delete). Swapping to league/flysystem-google-cloud-storage on Laravel 9+ then becomes a one-class change instead of a grep across the codebase.
This package is archived (last release Sept 2020) and pinned to Flysystem v1. It will not coexist with league/flysystem-aws-s3-v3:^3.0 or anything else requiring Flysystem 3, which means it effectively caps you at Laravel 8. If you're on Laravel 9+, use league/flysystem-google-cloud-storage and register the gcs driver yourself via Storage::extend() — the config shape above maps over almost 1:1, so the migration is mostly mechanical.
Uniform Bucket-Level Access kills default_acl and visibility => 'public'. If the bucket has UBLA enabled (the default for new buckets), any ACL-setting call returns a 400 — Cannot use ACL API with uniform bucket-level access. You must grant roles/storage.objectViewer to allUsers at the bucket IAM level instead, and set the disk config to 'visibility' => 'private' with default_acl => null. This is the #1 reason uploads "work in dev, fail in prod" after someone hardens the bucket.
url() is not a signed URL and doesn't check visibility. It happily returns https://storage.googleapis.com/{bucket}/{path} for a private object; the browser gets a 403. If you're serving private files, use temporaryUrl exclusively and set short expirations — signed URLs are bearer tokens once they leak into a page source or a log.
Signed URLs need a private key. temporaryUrl signs with the service account's private key. Under pure ADC on GCE/GKE there is no local private key, and signing fails — you need either a key file or the IAM signBlob permission with iamCredentials wired into the client. If you see signature errors only in production-on-GCP, this is why.
path_prefix is applied inconsistently across methods in some adapter versions. Verify url(), exists(), and delete() all resolve correctly with a prefix set before you rely on it in a multi-tenant design. A quick tinker round-trip catches this in two minutes.
Errors surface as raw Google\Cloud\Core\Exception\ServiceException. exists() returns false cleanly, but get() on a missing object throws League\Flysystem\FileNotFoundException (Laravel converts to Illuminate\Contracts\Filesystem\FileNotFoundException). Wrap user-facing retrieval in a try/catch — do not let a ServiceException with bucket names and project IDs escape to a 500 page.
Storage::fake('gcs') swaps in a local disk, which is exactly what you want for unit tests — but it means ACLs, signing, and getAdapter()->getTemporaryUrl() are not exercised. Keep a small integration test suite that hits a real throwaway bucket for the credential/ACL/signing path.
To customise the SDK client (timeouts, retries, httpOptions, emulator endpoints), the package's Storage::extend('gcs', ...) is registered by its own provider — you can re-register the same driver name from AppServiceProvider::boot() after the package provider to override it, constructing StorageClient with the extra options. Alternatively, extend the package's service provider and swap it in config/app.php.
GCS has no real directories. deleteDirectory() lists then deletes object-by-object — it is slow and billable on large trees, and it can partially fail. For user-deletion flows, prefer a lifecycle rule on the bucket (e.g., delete under tmp/ after 7 days) over imperative cleanup.
Cache the config, not the credentials. php artisan config:cache is fine because all env() calls live in config/filesystems.php. But never env() inside a service provider or controller — it returns null once cached, and you'll get a client built with no bucket.
How can I help you explore Laravel packages today?