carlosocarvalho/flysystem-cloudinary
Flysystem adapter that brings Cloudinary storage to Laravel's filesystem abstraction. Use the familiar Storage facade to upload, retrieve, and manage images and files on Cloudinary, combining cloud-based media delivery with Laravel's simple, driver-based file handling in your PHP app.
Install the adapter and Cloudinary SDK:
composer require carlosocarvalho/flysystem-cloudinary cloudinary/cloudinary_php
Confirm the package’s Flysystem major version before installing — this adapter targets Flysystem v3, so it fits Laravel 9+ / 10+ / 11+ projects. If you are on an older Laravel version still using Flysystem v1/v2, check compatibility first.
Where to look first
README.md — installation, driver registration, and the exact config keys.composer.json — required PHP version, Flysystem version, and whether a Laravel service provider is auto-discovered.src/ — the adapter class and any Laravel ServiceProvider / FilesystemManager extension.tests/ — the most reliable description of supported operations and edge cases.cloud_name, api_key, api_secret, and your upload preset/environment.Laravel disk configuration
Add a disk in config/filesystems.php. Exact key names depend on the package README, but the shape is typically:
'cloudinary' => [
'driver' => 'cloudinary',
'cloud_name' => env('CLOUDINARY_CLOUD_NAME'),
'api_key' => env('CLOUDINARY_API_KEY'),
'api_secret' => env('CLOUDINARY_API_SECRET'),
'url' => env('CLOUDINARY_URL'), // optional delivery/base URL
],
Then:
Storage::disk('cloudinary')->put('avatars/user-1.jpg', $contents);
$url = Storage::disk('cloudinary')->url('avatars/user-1.jpg');
If the package does not register a Laravel driver automatically, wire it manually through a service provider or use the adapter directly with Flysystem v3:
use League\Flysystem\Filesystem;
use Cloudinary\Cloudinary;
// Confirm exact adapter namespace from the package source.
use Carlosocarvalho\FlysystemCloudinary\CloudinaryAdapter;
$cloudinary = new Cloudinary([
'cloud' => [
'cloud_name' => config('services.cloudinary.cloud_name'),
'api_key' => config('services.cloudinary.api_key'),
'api_secret' => config('services.cloudinary.api_secret'),
],
]);
$filesystem = new Filesystem(new CloudinaryAdapter($cloudinary));
First use case
The quickest useful workflow is user-generated image uploads:
$path = $request->file('avatar')->storeAs(
'avatars',
$user->id . '.jpg',
'cloudinary'
);
$user->update(['avatar_path' => $path]);
Treat the returned path as the canonical identifier stored in your database, and generate URLs at render time rather than storing absolute Cloudinary URLs.
Use one disk per media purpose. Cloudinary distinguishes image, video, and raw resource types. Configure separate disks or path conventions for images, videos, and documents so the adapter does not have to infer resource type from file extensions inconsistently.
'cloudinary_images' => [/* image disk */],
'cloudinary_video' => [/* video disk */],
'cloudinary_raw' => [/* raw/document disk */],
Store paths, not URLs. Persist the Flysystem path / Cloudinary public_id in your model. Generate delivery URLs in accessors or API resources:
public function getAvatarUrlAttribute(): ?string
{
return $this->avatar_path
? Storage::disk('cloudinary')->url($this->avatar_path)
: null;
}
This lets you change cloud names, delivery domains, or transformation strategies without a data migration.
Use Flysystem for CRUD, Cloudinary SDK for transformations. Flysystem exposes file operations; it does not model Cloudinary transformations, eager uploads, upload presets, or advanced delivery options. A practical pattern is:
// Flysystem handles storage.
$path = $request->file('photo')->store('products', 'cloudinary');
// Cloudinary SDK handles delivery/transformations.
$url = cloudinary()->image($path)->format('auto')->quality('auto')->toUrl();
Upload from request objects. Laravel’s store() and storeAs() work through the Storage facade and are the least vendor-specific integration point. Avoid calling Cloudinary upload directly for normal CRUD; use the SDK only when you need Cloudinary-specific upload behavior.
$path = Storage::disk('cloudinary')->putFileAs(
'products',
$request->file('image'),
$product->id . '.jpg'
);
Handle streams for large files. For videos and large documents, prefer writeStream() / readStream() over loading whole files into memory:
$stream = fopen($request->file('video')->getRealPath(), 'r');
Storage::disk('cloudinary_video')->writeStream('videos/' . $id . '.mp4', $stream);
fclose($stream);
Wrap the disk behind your own service. Because this is a small package (22 stars), isolate it behind an application service or repository. That reduces the blast radius if you later swap Cloudinary for S3, or if the adapter’s path semantics need adjustment.
class MediaStorage
{
public function put(string $path, mixed $contents): string
{
Storage::disk('cloudinary')->put($path, $contents);
return $path;
}
}
Testing pattern. Storage::fake('cloudinary') will typically replace the disk with a local temporary disk, which is useful for upload flow tests but does not test Cloudinary-specific behavior. Use:
Storage::fake() for application-level tests.MediaStorage service for unit tests.Listing files. Use files(), allFiles(), and directories() carefully. Cloudinary folders are prefix-based rather than true directories, and listing is backed by the Cloudinary Admin API, which is paginated. For large media libraries, prefer a database index of known paths over repeatedly listing the disk.
Cloudinary paths are not filesystem paths. A Flysystem path like avatars/user-1.jpg maps to a Cloudinary public_id plus format and resource type. Some operations may not round-trip exactly:
exists('avatars/user-1.jpg') may fail if the actual public_id is avatars/user-1.resource_type; deleting a PDF as an image, for example, will not work.Test put() → exists() → get() → delete() with each media type you plan to support.
Visibility is not a simple boolean. Flysystem exposes public / private visibility, but Cloudinary has delivery types such as upload, private, and authenticated. Do not assume setVisibility() behaves like S3 ACLs. For private assets, use Cloudinary’s signed/private delivery mechanisms through the SDK rather than relying on Flysystem visibility alone.
temporaryUrl() may not be supported. If the adapter does not implement signed URL generation, Storage::temporaryUrl() will fail or fall back to url(). For private media, generate signed URLs with the Cloudinary SDK and document that exception in your codebase.
URL generation depends on disk config. Laravel’s Storage::url() needs a configured url or the adapter must implement URL generation. If Storage::url() returns an unexpected or empty value, check the disk’s url key and the adapter source before assuming the upload failed.
Overwriting the same path may serve stale CDN content. Cloudinary caches by public_id and version. If you replace avatars/user-1.jpg frequently, either use versioned paths (avatars/user-1-{timestamp}.jpg) or invalidate the CDN. Do not assume an overwrite is immediately visible everywhere.
Directory operations are approximate. Cloudinary does not have real directories. createDirectory() may be a no-op or unsupported, and deleting a “folder” may not cascade to its assets. Treat folders as naming conventions, not resources with lifecycle semantics.
Listing can be slow or incomplete at scale. Admin API listing is paginated and subject to rate limits. If allFiles() returns fewer results than expected, check whether the adapter paginates fully. For production dashboards, maintain your own media table rather than scanning Cloudinary.
Watch extension and format behavior for raw files. Uploading .pdf, .zip, .csv, or extensionless files may require the raw resource type. If uploads succeed but downloads or deletes fail, verify the resource type mapping and path normalization.
Config caching and environment variables. After changing Cloudinary credentials in .env, run php artisan config:clear or php artisan config:cache as appropriate. A common production failure is stale cached config pointing to a development Cloudinary cloud.
Error handling. Flysystem throws exceptions such as UnableToWriteFile, UnableToReadFile, and UnableToDeleteFile. Cloudinary API errors may be wrapped or may surface directly depending on the adapter. Catch at your service boundary, log the original path and Cloudinary response, and return a domain-level error rather than leaking vendor exceptions.
Pin the version and review the source. With a small community package, pin a specific tag in composer.lock, read the adapter’s handling of public_id, resource types, and listing, and keep your own integration tests around it. The adapter is a thin bridge; the fewer assumptions your application makes about its internals, the easier it is to replace.
Extension points. If path mapping or resource-type inference does not fit your application, wrap the adapter rather than patching vendor code:
MediaStorage service before calling Flysystem.path, resource_type, and format.Laravel version compatibility. Flysystem v3 aligns with Laravel 9+. On older Laravel versions, verify the Flysystem major version before installing; mixing Flysystem v2 adapters with a v3 filesystem manager will produce type errors at runtime, usually when the disk is first resolved.
How can I help you explore Laravel packages today?