Upload once.
Serve everywhere, fast.
Send a file to the API, get back a public URL. Every file is cached for a full year at the edge.
Upload a file
Send a multipart/form-data POST to /upload with your API
key. Add an optional folder field (products/categories); missing folders are created
automatically.
<?php
$ch = curl_init('https://cdn.csndev.com/upload');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: YOUR_API_KEY']);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
'file' => new CURLFile($filePath, mime_content_type($filePath), basename($filePath)),
'folder' => 'products/categories', // optional, auto-created
]);
$data = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && $data['success']) {
echo $data['url']; // https://cdn.csndev.com/files/products/categories/<uuid>.jpg
} else {
echo 'Upload failed: ' . ($data['error'] ?? 'Unknown error');
}
async function uploadFile(file, folder = '', apiKey) {
const formData = new FormData();
formData.append('file', file);
if (folder) formData.append('folder', folder); // optional
const res = await fetch('https://cdn.csndev.com/upload', {
method: 'POST',
headers: { 'X-API-Key': apiKey }, // never hardcode the key in public pages
body: formData,
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Upload failed');
return data.url;
}
# Upload
curl -X POST https://cdn.csndev.com/upload \
-H "X-API-Key: your-secret-upload-key" \
-F "file=@photo.jpg" \
-F "folder=products/categories"
# Delete (by path or full URL)
curl -X DELETE https://cdn.csndev.com/delete \
-H "X-API-Key: your-secret-upload-key" \
-H "Content-Type: application/json" \
-d '{"file":"products/categories/<filename>"}'
Public files need no auth. Folder prefixes are kept.
https://cdn.csndev.com/files/products/categories/<filename>
API key required. Works with a path or a full URL.
DELETE /delete {"file": "..."}
Laravel integration
Five steps. After this, every upload and delete in your app goes through
MediaRepository, to the CDN or local disk depending on .env.
1Add CDN variables to .env
Set MEDIA_CDN_ENABLED=false to keep using local public
storage.
MEDIA_CDN_ENABLED=true
MEDIA_CDN_URL="https://cdn.csndev.com/upload"
MEDIA_CDN_KEY="your-cdn-secret-key"
2Register the service config
config/services.php'media_cdn' => [
'enabled' => env('MEDIA_CDN_ENABLED', false),
'url' => env('MEDIA_CDN_URL'),
'key' => env('MEDIA_CDN_KEY'),
],
Run php artisan config:clear after editing.
3Add the thumbnail accessor
Put this in the User model or any model that has a media thumbnail. CDN
URLs are used as-is; older local files still resolve through Storage.
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Storage;
public function thumbnail(): Attribute
{
$source = asset('default/profile.png');
$media = $this->media;
if ($media && (
str_starts_with($media->src, 'http://') ||
str_starts_with($media->src, 'https://')
)) {
$source = $media->src; // CDN file
} elseif ($media && Storage::exists($media->src)) {
$source = Storage::url($media->src); // legacy local file
}
return Attribute::make(get: fn () => $source);
}
4Update MediaRepository
One global place for upload, replace and delete. The new optional
$folder argument is created on the CDN automatically (and used as a sub-folder for local
storage). Old files are removed on replace and delete.
<?php
namespace App\Repositories;
use App\Models\Media;
use Arafat\LaravelRepository\Repository;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class MediaRepository extends Repository
{
public static function model()
{
return Media::class;
}
/**
* Store an uploaded file (CDN or local) with an optional folder.
*/
public static function storeByRequest(
UploadedFile $file,
string $path,
?string $type = null,
?string $folder = null
): Media {
self::assertCdnConfigured();
$src = self::putFile($file, $path, $folder);
return self::create([
'type' => self::resolveType($file, $type),
'src' => $src,
'name' => $file->getClientOriginalName(),
'extension' => $file->extension(),
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
]);
}
/**
* Replace the file of an existing media record.
*/
public static function updateByRequest(
UploadedFile $file,
string $path,
?string $type,
Media $media,
?string $folder = null
): Media {
self::assertCdnConfigured();
$oldSrc = (string) $media->src;
$newSrc = self::putFile($file, $path, $folder);
$media->update([
'type' => self::resolveType($file, $type),
'name' => $file->getClientOriginalName(),
'src' => $newSrc,
'extension' => $file->extension(),
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
]);
self::removeFile($oldSrc); // only after the new file is safe
return $media;
}
/**
* Delete the stored file and the media record.
*/
public static function deleteByMedia(Media $media): bool
{
self::removeFile((string) $media->src);
return (bool) $media->delete();
}
/* ---------------------------------------------------------------- */
private static function putFile(UploadedFile $file, string $path, ?string $folder): string
{
$folder = self::cleanFolder($folder);
if (self::useCdn()) {
return self::uploadToCdn($file, $folder)['url'];
}
return Storage::disk('public')->put(
trim($path . '/' . $folder, '/'),
$file
);
}
private static function removeFile(string $src): void
{
if ($src === '') {
return;
}
if (str_starts_with($src, 'http://') || str_starts_with($src, 'https://')) {
// CDN file: send only the path, not the full URL
$file = self::useCdn() ? self::cdnPath($src) : null;
if ($file) {
try {
Http::timeout(30)
->withHeaders(['X-API-Key' => config('services.media_cdn.key')])
->delete(self::cdnDeleteUrl(), ['file' => $file]);
} catch (\Throwable $e) {
report($e); // never block the request if CDN delete fails
}
}
return;
}
if (Storage::disk('public')->exists($src)) {
Storage::disk('public')->delete($src);
}
}
private static function uploadToCdn(UploadedFile $file, string $folder = ''): array
{
$response = Http::timeout(60)
->withHeaders(['X-API-Key' => config('services.media_cdn.key')])
->attach('file', fopen($file->getRealPath(), 'r'), $file->getClientOriginalName())
->post(config('services.media_cdn.url'), ['folder' => $folder]);
$data = $response->json();
if (! $response->successful() || ! is_array($data) || empty($data['success']) || empty($data['url'])) {
throw new \RuntimeException('CDN file upload failed: ' . ($data['error'] ?? 'Unknown CDN error'));
}
return $data;
}
/** "products/categories" style only; blocks "..", spaces, symbols. */
private static function cleanFolder(?string $folder): string
{
$folder = trim((string) $folder, '/');
if ($folder === '') {
return '';
}
if (! preg_match('/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/', $folder) || str_contains($folder, '..')) {
throw new \InvalidArgumentException('Invalid CDN folder name.');
}
return $folder;
}
/**
* https://cdn.csndev.com/files/products/cat/uuid.jpg => products/cat/uuid.jpg
* Returns null if the URL does not belong to our CDN domain.
*/
private static function cdnPath(string $src): ?string
{
$base = parse_url((string) config('services.media_cdn.url'));
$file = parse_url($src);
if (($file['host'] ?? null) !== ($base['host'] ?? null)) {
return null;
}
$path = preg_replace('#^/files/#', '', $file['path'] ?? '');
return ltrim($path, '/') ?: null;
}
private static function cdnDeleteUrl(): string
{
return preg_replace('#/upload/?$#', '/delete', config('services.media_cdn.url'));
}
private static function resolveType(UploadedFile $file, ?string $type): string
{
if ($type) {
return $type;
}
$ext = $file->extension();
return in_array(strtolower($ext), ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']) ? 'image' : $ext;
}
private static function useCdn(): bool
{
return (bool) config('services.media_cdn.enabled');
}
private static function assertCdnConfigured(): void
{
if (self::useCdn() && (blank(config('services.media_cdn.url')) || blank(config('services.media_cdn.key')))) {
throw new \RuntimeException('CDN upload is enabled but not configured properly.');
}
}
}
5Call it from a controller
app/Http/Controllers/ProfileController.phpuse App\Repositories\MediaRepository;
// Upload into a folder
$media = MediaRepository::storeByRequest(
$request->file('photo'),
'users', // local fallback path
null, // type (auto-detected)
'users/avatars' // CDN folder, auto-created
);
$user->update(['media_id' => $media->id]);
// Replace
MediaRepository::updateByRequest($request->file('photo'), 'users', null, $user->media, 'users/avatars');
// Delete file + record
MediaRepository::deleteByMedia($user->media);
// In Blade
<img src="{{ $user->thumbnail }}" alt="{{ $user->name }}">
'photo' => 'required|image|max:4096'.What this service does
Each upload gets a unique filename, optionally inside a folder created on demand, and is written to disk once.
Responses are marked immutable for a year, so browsers and
proxies stop re-asking for them.
Files stream straight from disk with range support, so video and audio can seek instantly.