yoelpc4/laravel-cloudinary
A Laravel integration for Cloudinary that simplifies uploading, managing, and delivering images and videos through Cloudinary’s API. Configure credentials, generate transformations and URLs, and handle media storage within familiar Laravel conventions.
Install and wire it up (5 minutes):
composer require yoelpc4/laravel-cloudinary
php artisan vendor:publish --tag=cloudinary-config # tag name: check README
Add a disk to config/filesystems.php:
'cloudinary' => [
'driver' => 'cloudinary',
// credentials are usually read from config/cloudinary.php, not here
],
Set credentials in .env — Cloudinary's own SDK accepts a single CLOUDINARY_URL (cloudinary://API_KEY:API_SECRET@CLOUD_NAME), which the driver almost certainly reads. If not, use the discrete CLOUDINARY_CLOUD_NAME / CLOUDINARY_API_KEY / CLOUDINARY_API_SECRET trio and confirm the key names against the published config.
Where to look first:
config/cloudinary.php after publishing — this is where the credential mapping, default folder, and upload params (upload_preset, secure, resource_type) live.Storage::extend('cloudinary', ...) driver name. If it's not cloudinary, your disk config is wrong and you'll get a confusing "Driver [] is not supported" error.url(), temporaryUrl(), visibility(), directoryExists() actually work. Don't assume S3 parity.First use case — user avatars:
$path = $request->file('avatar')->store('avatars', 'cloudinary');
// or with a deterministic public ID
Storage::disk('cloudinary')->putFileAs('avatars', $request->file('avatar'), "user-{$user->id}");
$url = Storage::disk('cloudinary')->url($path);
Store the returned path/public ID in the database — not a full URL — so you can regenerate delivery URLs with different transformations later without a migration.
Keep the disk out of your domain code. Inject Illuminate\Filesystem\FilesystemManager or type-hint a small MediaStorage interface that wraps Storage::disk('cloudinary'). Given the package's size (33 stars, single maintainer), an interface gives you a clean swap to S3 + imgproxy or Cloudinary's own SDK if maintenance stalls.
Treat "path" as a Cloudinary public ID. Cloudinary has no real directories — folders are just a prefix on the public ID (avatars/user-42), and resource_type/type are separate dimensions the disk abstraction flattens. Practical consequence: store the full public ID including folder and, if you support multiple formats, the format too. photo.jpg and photo.png collide once the extension is stripped.
Transformations are the reason to use Cloudinary — plan for them. The Flysystem disk usually returns the plain delivery URL, not a transformed one. Two clean options:
// A. Store public ID, build URLs with the Cloudinary SDK helper or a URL builder
$url = cloudinary_url($publicId, ['width' => 400, 'height' => 400, 'crop' => 'fill']);
// B. Use named transformations defined in the Cloudinary dashboard
$url = cloudinary_url($publicId, ['transformation' => 't_avatar_sm']);
Option B is strongly preferred: named transformations keep URL logic out of PHP, are editable without deploys, and are easier to audit against quota. Use eager transformations on upload for your 2–3 most common sizes so the first page view doesn't pay the derivation cost.
Browser-direct uploads for anything large. Don't proxy 50 MB videos through PHP. Get a signature server-side, upload straight to Cloudinary from the client, and persist the returned public_id:
$timestamp = time();
$signature = app(\Cloudinary\Cloudinary::class)->uploadApi()->signature([
'timestamp' => $timestamp,
'folder' => "tenants/{$tenant->id}/videos",
]);
return response()->json(compact('signature', 'timestamp'));
This bypasses the driver entirely for writes but is the pattern that actually scales; use the disk for reads/URLs and admin operations.
Private assets: if the driver exposes temporaryUrl(), it's backed by Cloudinary's private/authenticated delivery with a signed expiry. Verify it against your plan — authenticated delivery for raw files (PDFs, ZIPs) behaves differently from images and is a common source of 401s in production while working fine in staging.
Multi-tenant isolation: prefix every public ID with tenants/{$id}/. Deleting a tenant means the Admin API (delete_resources_by_prefix) — the Flysystem deleteDirectory() may not map to that, so confirm or call the Admin API directly.
Development/production split: keep FILESYSTEM_DISK=local locally and switch via env. Nothing about Cloudinary degrades gracefully offline, and tests that depend on a live cloud will be flaky.
raw as an escape hatch you validate per use case.exists() is expensive. Depending on implementation it may hit the Admin API, which is rate-limited (roughly 500 requests/hour on the free tier). Never call it inside a loop or a Blade partial. Cache presence in your own DB column instead.Storage::delete() removes the asset but the CDN copy can persist until invalidation — and CDN invalidation is a paid feature on some plans. If a user deletes an avatar, either accept the stale URL or append a version/v parameter to force a fresh fetch.php artisan config:cache, any env() call outside config/*.php returns null. If the driver reads env('CLOUDINARY_URL') directly in its own code path, you must run config:cache in deploy and make sure the published config is what's actually used. Symptom: works in php artisan serve, 401s in production.cloudinary_url() with no SDK credentials). Pick one source of truth — usually a single config/cloudinary.php that the service provider also feeds to the SDK.public_id has no extension. If you reconstruct paths from DB values that include .jpg, lookups silently miss. Normalize on write.put() can return a public ID while the asset is still pending. Transformation URLs 404 until processing completes. Use the upload response's status or a notification_url webhook rather than assuming success.Storage::fake('cloudinary') swaps in a local temp disk, so your upload/delete flow tests pass — but they validate nothing about transformations, delivery URLs, or credentials. Add one thin integration test (or a scheduled smoke check) that uploads a 1×1 PNG and asserts a 200 on a transformed URL.^1.0 can surprise you. Lock the minor and watch the Flysystem major version constraint — Flysystem 3 API changes broke most community adapters and this one is small enough that it may lag a Laravel release.cloudinary/cloudinary_php SDK directly behind your own MediaStorage service. That's ~80 lines and removes the dependency risk entirely — worth prototyping early rather than after you've spread Storage::disk('cloudinary') calls across the codebase.How can I help you explore Laravel packages today?