cedricziel/flysystem-gcs
Flysystem adapter for Google Cloud Storage that lets Laravel and PHP applications use GCS buckets as filesystem disks for uploading, reading, listing, and deleting objects through a familiar Flysystem API.
First, check your Flysystem generation. This adapter implements League\Flysystem\AdapterInterface (Flysystem 1.x). That means it works with Laravel ≤ 8 only. If you're on Laravel 9+, stop here and use league/flysystem-google-cloud-storage — Laravel ships a built-in gcs driver for it — because Composer will refuse to install this package alongside league/flysystem ^2|^3.
Minimal path on Laravel 8:
composer require cedricziel/flysystem-gcs
It pulls in google/cloud-storage (the "gcloud PHP library"), which is what actually talks to GCS. Authenticate the same way you would for any Google client:
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
Where to look first: open vendor/cedricziel/flysystem-gcs/src/. There is essentially one adapter class in the CedricZiel\FlysystemGcs namespace — read its constructor signature before writing config, since that's the source of truth for what options (bucket handle, optional path prefix, visibility) it accepts. The README is thin; the code is ~a few hundred lines and is faster to read than to search for docs.
Register the disk in App\Providers\AppServiceProvider::boot() (the package ships no Laravel service provider):
use Google\Cloud\Storage\StorageClient;
use League\Flysystem\Filesystem;
use Illuminate\Support\Facades\Storage;
Storage::extend('gcs', function ($app, $config) {
$client = new StorageClient([
'projectId' => $config['project_id'],
'keyFilePath' => $config['key_file'] ?? null, // omit to use ADC
]);
$adapter = new \CedricZiel\FlysystemGcs\GoogleCloudStorageAdapter(
$client->bucket($config['bucket_name']),
$config['path_prefix'] ?? null
);
return new Filesystem($adapter);
});
First use case: offloading user uploads out of the local disk.
// config/filesystems.php
'gcs' => [
'driver' => 'gcs',
'project_id' => env('GOOGLE_CLOUD_PROJECT_ID'),
'bucket_name' => env('GOOGLE_CLOUD_STORAGE_BUCKET'),
'key_file' => env('GOOGLE_APPLICATION_CREDENTIALS'),
'path_prefix' => env('GOOGLE_CLOUD_STORAGE_PATH_PREFIX'),
],
Storage::disk('gcs')->putFileAs('avatars', $request->file('avatar'), "{$user->id}.jpg");
Namespace a shared bucket per environment. GCS buckets are often shared staging/production; the adapter's path_prefix option is the cheapest isolation you get. Set it from APP_ENV and every key becomes production/avatars/.... Do this on day one — retrofitting prefixes onto existing objects is a copy-and-delete job.
Wrap in a caching adapter for any listing UI. Flysystem 1 has League\Flysystem\Cached\CachedAdapter, and GCS charges per list operation while being slow on large prefixes. A media library that calls listContents() on every page load will be noticeably better with a cache store attached. Cache invalidation is manual, so scope it to read-mostly prefixes.
Stream large objects; don't get() them. Storage::disk('gcs')->readStream($path) and writeStream($path, $resource) map onto the gcloud library's streaming API and avoid holding whole files in PHP memory. For uploads, putFile()/putFileAs() already stream from the uploaded temp file. For downloads, return a streamed response:
return response()->streamDownload(function () use ($path) {
fpassthru(Storage::disk('gcs')->readStream($path));
}, basename($path));
Signed URLs need a subclass. The adapter does not implement getUrl, so Storage::url() and Storage::temporaryUrl() will throw or return nothing useful. The clean fix is a thin subclass that exposes signed URLs through the underlying bucket handle:
class SignedUrlGcsAdapter extends GoogleCloudStorageAdapter
{
public function getTemporaryUrl(string $path, $expiration, array $options = []): string
{
return $this->bucket->object($this->applyPathPrefix($path))
->signedUrl(new \DateTime("+{$expiration} minutes"), $options);
}
}
Bind the subclass in Storage::extend and call Storage::disk('gcs')->getDriver()->getTemporaryUrl(...). Note this requires the service account to have roles/iam.serviceAccountTokenCreator on itself, or signing will fail — a common 403.
Reach through the adapter for bucket-level operations. Anything the Flysystem surface doesn't cover (object lifecycle, predefinedAcl, compose, resumable multi-chunk uploads, signed policy documents) is one hop away:
$bucket = Storage::disk('gcs')->getAdapter()->getBucket();
$bucket->object('avatars/1.jpg')->update(['metadata' => ['cacheControl' => 'public, max-age=3600']]);
Keep the disk name stable across the migration. Name the disk gcs and keep it that way. When you eventually move to Laravel 9+ and league/flysystem-google-cloud-storage, only the Storage::extend block and config keys change — every Storage::disk('gcs')->... call in your app stays put. That is the single highest-leverage decision you can make here.
Test against a local disk, never a real bucket. Storage::fake('gcs') in Laravel swaps in a local driver rooted under storage/framework/testing/disks/gcs, even for custom disks. Reserve real-bucket tests for a single smoke test behind a separate test suite.
This package is archived (last release 2021-02-02, 13 stars). Treat it as frozen: pin it ("cedricziel/flysystem-gcs": "~1.0") and commit composer.lock, because the google/cloud-storage transitive dependency will keep moving and is where any security fixes actually land. Budget for the migration rather than expecting upstream fixes.
GCS has no real directories. createDirectory() writes a zero-byte placeholder object (or is a no-op); deleteDirectory() lists and deletes objects one by one, and on a prefix with thousands of objects it will be slow and bill per operation. Prefer lifecycle rules on the bucket for bulk expiry, and never deleteDirectory('') from a web request.
No rename — it's copy-then-delete. Storage::move() on a 2 GB object is a full server-side copy plus a delete, and it is not atomic. Concurrent readers can see both or neither. Design for immutable keys instead of moving things around.
Visibility is ACL-based, and Uniform Bucket-Level Access breaks it. If the bucket has UBLA enabled, object ACLs are off and any visibility: public / setVisibility() path fails with a 400-level ServiceException. With UBLA you serve files through signed URLs or a CDN, full stop.
Errors arrive as Google exceptions, not Laravel ones. The adapter doesn't consistently translate Google\Cloud\Core\Exception\ServiceException into Flysystem's typed exceptions, so a missing object may surface as a raw 404 rather than UnableToReadFile. Wrap reads defensively and check Storage::disk('gcs')->exists($path) first; catch ServiceException at the boundary and map to your domain errors.
Credential gotchas, in order of frequency:
GOOGLE_APPLICATION_CREDENTIALS pointing at a file the PHP-FPM user can't read → silent fallback to metadata server, then a confusing timeout.StorageClient wants an array under keyFile, not a string. json_decode(env('GCP_CREDENTIALS'), true).key_file entirely and rely on Workload Identity — don't bake keys into images.roles/storage.objectCreator/objectAdmin on the bucket, not on the project.URL generation is not implemented. Storage::url() will not work. Anything you build around public links must go through signed URLs (see the subclass pattern above) or a public bucket + CDN. This is the most common surprise for people porting from superbalist/flysystem-google-storage.
Metadata is partly synthetic. getSize() and getMimetype() may require an extra object metadata lookup per call — avoid calling them in a loop over listContents(), which is already the slowest operation on the adapter. Fetch the listing once and read size/mimetype from the returned array.
Debugging checklist when something silently fails: (1) Storage::disk('gcs')->getAdapter()->getBucket()->name() to confirm the bucket actually resolved; (2) check whether path_prefix is duplicating (production/production/...) — a classic after copying config between environments; (3) enable Google\Cloud\Core\RequestBuilder logging / LoggerInterface on StorageClient to see the raw HTTP call; (4) verify the object exists in the console before assuming an SDK bug, since the flat namespace means a trailing slash in a prefix silently changes the key.
Extension point worth knowing: because the whole surface is one adapter class, subclassing is the intended way to add anything missing (signed URLs, getUrl, cache-control headers, custom metadata on write). Subclass, don't fork — the package is archived and you will not be pushing fixes back upstream.
How can I help you explore Laravel packages today?