Laravel ile S3'e Dosya Yukleme Sistemi
Dosya yukleme her web projesinde karsimiza cikiyor. Basit bir move() ile basliyoruz ama production'a gecince isler karismiyor: disk doluyor, ayni dosya defalarca yukleniyor, guvenlik aciklari ortaya cikiyor, sunucu degisince dosyalar kayboluyor. Bu yazida Laravel ile production-ready bir S3 dosya yukleme sistemi kuracagiz. Deduplication, guvenlik, presigned URL ve chunked upload dahil.
Neden S3?
Local storage ile baslayan her projenin basi eninde sonunda derde giriyor:
- Disk dolma: Sunucunun diski 20GB, kullanicilar 50GB dosya yukledi
- Sunucu tasima: Yeni sunucuya geciste dosyalar tasinmadi veya eksik kaldi
- Load balancer: 2 sunucu var, dosya birinde, diger sunucudan erisilemez
- Yedekleme: Sunucu coktugunde dosyalar gitti
- CDN: Dosyalari kullaniciya yakin noktalardan sunmak icin ekstra is
S3 (veya uyumlu servisler: DigitalOcean Spaces, MinIO, Cloudflare R2) bunlarin hepsini cozuyor. Artik dosyalar sunucudan bagimsiz, sinirsiz alan var, CDN entegrasyonu kolay.
Veritabani Schemasi
Once dosya kayitlarini tutacak tabloyu olusturalim. Burada kritik alan hash -- ayni dosyanin tekrar yuklenmesini onluyor.
php artisan make:migration create_uploads_table
// database/migrations/xxxx_create_uploads_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('uploads', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
// Dosya bilgileri
$table->string('original_name'); // Kullanicinin yukladigi isim
$table->string('filename'); // S3'teki benzersiz isim
$table->string('path'); // S3'teki tam yol
$table->string('disk')->default('s3'); // Storage disk
$table->string('mime_type');
$table->unsignedBigInteger('size'); // Byte cinsinden
// Deduplication
$table->string('hash', 64)->index(); // SHA-256 hash
// Metadata
$table->json('metadata')->nullable(); // Boyutlar, sure, vs.
$table->string('collection')->nullable()->index(); // avatars, documents, vs.
// Erisim kontrolu
$table->enum('visibility', ['public', 'private'])->default('private');
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('uploads');
}
};
Model:
// app/Models/Upload.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class Upload extends Model
{
use SoftDeletes;
protected $fillable = [
'user_id', 'original_name', 'filename', 'path',
'disk', 'mime_type', 'size', 'hash', 'metadata',
'collection', 'visibility', 'expires_at',
];
protected $casts = [
'metadata' => 'array',
'size' => 'integer',
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function getUrl(): string
{
if ($this->visibility === 'public') {
return Storage::disk($this->disk)->url($this->path);
}
return $this->getTemporaryUrl();
}
public function getTemporaryUrl(int $minutes = 60): string
{
return Storage::disk($this->disk)->temporaryUrl(
$this->path,
now()->addMinutes($minutes)
);
}
public function getHumanSize(): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$size = $this->size;
$unit = 0;
while ($size >= 1024 && $unit < count($units) - 1) {
$size /= 1024;
$unit++;
}
return round($size, 2) . ' ' . $units[$unit];
}
public function isImage(): bool
{
return str_starts_with($this->mime_type, 'image/');
}
public function scopeCollection($query, string $collection)
{
return $query->where('collection', $collection);
}
}
S3 Konfigurasyonu
# .env
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_DEFAULT_REGION=eu-central-1
AWS_BUCKET=projeman-uploads
AWS_URL=https://projeman-uploads.s3.eu-central-1.amazonaws.com
// config/filesystems.php
'disks' => [
// ...
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => true,
],
],
Cloudflare R2 kullaniyorsaniz endpoint eklemeniz yeterli:
AWS_ENDPOINT=https://ACCOUNT_ID.r2.cloudflarestorage.com
AWS_USE_PATH_STYLE_ENDPOINT=true
Upload Service
Tum yukleme mantigi tek bir service'te. Controller'da is mantigi olmasin.
// app/Services/UploadService.php
<?php
namespace App\Services;
use App\Models\Upload;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UploadService
{
/**
* Dosya yukle ve kayit olustur
*/
public function upload(
UploadedFile $file,
string $collection = 'default',
string $visibility = 'private',
?int $userId = null
): Upload {
// Hash hesapla (deduplication icin)
$hash = hash_file('sha256', $file->getRealPath());
// Ayni hash'e sahip dosya var mi?
$existing = Upload::where('hash', $hash)
->where('collection', $collection)
->first();
if ($existing) {
// Dosya zaten var, yeni kayit olustur ama ayni path'i goster
return Upload::create([
'user_id' => $userId ?? auth()->id(),
'original_name' => $file->getClientOriginalName(),
'filename' => $existing->filename,
'path' => $existing->path,
'disk' => $existing->disk,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'hash' => $hash,
'metadata' => $this->extractMetadata($file),
'collection' => $collection,
'visibility' => $visibility,
]);
}
// Benzersiz dosya adi olustur
$filename = $this->generateFilename($file);
$directory = $this->getDirectory($collection);
$path = $directory . '/' . $filename;
// S3'e yukle
$s3Visibility = $visibility === 'public' ? 'public' : 'private';
Storage::disk('s3')->put($path, file_get_contents($file->getRealPath()), [
'visibility' => $s3Visibility,
'ContentType' => $file->getMimeType(),
'CacheControl' => 'max-age=31536000', // 1 yil cache
]);
// Veritabanina kaydet
return Upload::create([
'user_id' => $userId ?? auth()->id(),
'original_name' => $file->getClientOriginalName(),
'filename' => $filename,
'path' => $path,
'disk' => 's3',
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'hash' => $hash,
'metadata' => $this->extractMetadata($file),
'collection' => $collection,
'visibility' => $visibility,
]);
}
/**
* Dosya sil (soft delete + S3'ten kaldir)
*/
public function delete(Upload $upload): bool
{
// Ayni dosyayi kullanan baska kayit var mi?
$otherReferences = Upload::where('path', $upload->path)
->where('id', '!=', $upload->id)
->whereNull('deleted_at')
->exists();
if (!$otherReferences) {
// Baska referans yoksa S3'ten de sil
Storage::disk($upload->disk)->delete($upload->path);
}
return $upload->delete();
}
/**
* Benzersiz dosya adi: tarih bazli dizin + UUID + orijinal uzanti
*/
private function generateFilename(UploadedFile $file): string
{
$extension = $file->getClientOriginalExtension();
$extension = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $extension));
return Str::uuid() . '.' . $extension;
}
/**
* Koleksiyona gore dizin: uploads/avatars/2026/02
*/
private function getDirectory(string $collection): string
{
$date = now();
return sprintf(
'uploads/%s/%s/%s',
$collection,
$date->format('Y'),
$date->format('m')
);
}
/**
* Dosyadan metadata cikar (gorsel boyutlari, vs.)
*/
private function extractMetadata(UploadedFile $file): array
{
$metadata = [];
if (str_starts_with($file->getMimeType(), 'image/')) {
$imageInfo = @getimagesize($file->getRealPath());
if ($imageInfo) {
$metadata['width'] = $imageInfo[0];
$metadata['height'] = $imageInfo[1];
}
}
return $metadata;
}
}
Controller
// app/Http/Controllers/UploadController.php
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UploadRequest;
use App\Services\UploadService;
use App\Models\Upload;
use Illuminate\Http\JsonResponse;
class UploadController extends Controller
{
public function __construct(
private UploadService $uploadService
) {}
public function store(UploadRequest $request): JsonResponse
{
$uploads = [];
foreach ($request->file('files') as $file) {
$upload = $this->uploadService->upload(
file: $file,
collection: $request->input('collection', 'default'),
visibility: $request->input('visibility', 'private'),
);
$uploads[] = [
'id' => $upload->id,
'name' => $upload->original_name,
'size' => $upload->getHumanSize(),
'url' => $upload->getUrl(),
'mime_type' => $upload->mime_type,
];
}
return response()->json([
'message' => count($uploads) . ' dosya basariyla yuklendi.',
'uploads' => $uploads,
]);
}
public function destroy(Upload $upload): JsonResponse
{
$this->authorize('delete', $upload);
$this->uploadService->delete($upload);
return response()->json([
'message' => 'Dosya silindi.',
]);
}
}
Validation:
// app/Http/Requests/UploadRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UploadRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'files' => ['required', 'array', 'max:10'],
'files.*' => [
'required',
'file',
'max:10240', // 10MB
'mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,zip',
],
'collection' => ['nullable', 'string', 'max:50', 'alpha_dash'],
'visibility' => ['nullable', 'in:public,private'],
];
}
public function messages(): array
{
return [
'files.*.max' => 'Dosya boyutu en fazla 10MB olabilir.',
'files.*.mimes' => 'Gecersiz dosya turu.',
'files.max' => 'Tek seferde en fazla 10 dosya yukleyebilirsiniz.',
];
}
}
Drag & Drop Frontend
Basit ama etkili bir drag-and-drop arayuzu. Alpine.js ile yapiyoruz:
{{-- resources/views/components/upload/dropzone.blade.php --}}
<div
x-data="fileUploader()"
x-on:dragover.prevent="isDragging = true"
x-on:dragleave.prevent="isDragging = false"
x-on:drop.prevent="handleDrop($event)"
:class="isDragging ? 'border-primary-500 bg-primary-50' : 'border-gray-300 bg-white'"
class="relative border-2 border-dashed rounded-lg p-8 text-center transition-colors duration-200"
>
<input
type="file"
x-ref="fileInput"
x-on:change="handleFiles($event.target.files)"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.zip"
class="hidden"
/>
<div x-show="!isUploading">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="mt-4 text-sm text-gray-600">
Dosyalari surukleyip birakin veya
<button
type="button"
x-on:click="$refs.fileInput.click()"
class="text-primary-600 hover:text-primary-500 font-medium"
>
dosya secin
</button>
</p>
<p class="mt-1 text-xs text-gray-500">
JPG, PNG, PDF, DOC, XLS, ZIP - Max 10MB
</p>
</div>
{{-- Progress --}}
<div x-show="isUploading" class="space-y-3">
<template x-for="(file, index) in files" :key="index">
<div class="flex items-center gap-3 text-left">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-700 truncate" x-text="file.name"></p>
<div class="mt-1 w-full bg-gray-200 rounded-full h-2">
<div
class="bg-primary-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${file.progress}%`"
></div>
</div>
</div>
<span
x-show="file.progress === 100"
class="text-success-500 text-sm font-medium"
>Tamam</span>
</div>
</template>
</div>
</div>
<script>
function fileUploader() {
return {
isDragging: false,
isUploading: false,
files: [],
handleDrop(event) {
this.isDragging = false;
this.handleFiles(event.dataTransfer.files);
},
async handleFiles(fileList) {
if (!fileList.length) return;
this.isUploading = true;
this.files = Array.from(fileList).map(f => ({
name: f.name,
progress: 0,
file: f,
}));
const formData = new FormData();
this.files.forEach(f => formData.append('files[]', f.file));
formData.append('collection', 'documents');
try {
const response = await fetch('/api/uploads', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
alert(error.message || 'Yukleme hatasi');
return;
}
const data = await response.json();
this.files.forEach(f => f.progress = 100);
// Event gonder (parent component dinleyebilir)
this.$dispatch('files-uploaded', { uploads: data.uploads });
} catch (error) {
alert('Yukleme sirasinda bir hata olustu.');
console.error(error);
} finally {
setTimeout(() => {
this.isUploading = false;
this.files = [];
}, 2000);
}
}
};
}
</script>
Guvenlik
Dosya yukleme guvenlikte en zayif halkalardan biri. Su noktalara dikkat etmek gerekiyor:
MIME Validation
Client'in gonderdigi MIME type'a guvenmeyin. Sunucu tarafinda kontrol edin:
// AppServiceProvider veya middleware
use Illuminate\Validation\Rules\File;
// Validation rule'da:
'files.*' => [
'required',
File::types(['jpg', 'png', 'pdf', 'docx'])
->max(10 * 1024), // 10MB
],
Dosya Adi Sanitizasyonu
Kullanicinin verdigi dosya adini ASLA direkt kullanmayin:
// YANLIS - guvenlik acigi
$path = $file->storeAs('uploads', $file->getClientOriginalName());
// DOGRU - UUID kullanin
$path = $file->storeAs('uploads', Str::uuid() . '.' . $file->extension());
S3 Bucket Policy
Bucket'i public yapmayin. Private tutup presigned URL ile erisin:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyPublicAccess",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::projeman-uploads/*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalAccount": "123456789012"
}
}
}
]
}
Presigned URL ile Direkt Upload
Buyuk dosyalarda sunucuyu bypass edip direkt S3'e yuklemek performans acisindan cok daha iyi. Kullanici dosyayi seciyor, backend presigned URL veriyor, frontend direkt S3'e gonderiyor.
// app/Http/Controllers/PresignedUploadController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PresignedUploadController extends Controller
{
public function createPresignedUrl(Request $request): JsonResponse
{
$request->validate([
'filename' => 'required|string|max:255',
'content_type' => 'required|string',
'size' => 'required|integer|max:104857600', // 100MB
]);
$extension = pathinfo($request->filename, PATHINFO_EXTENSION);
$key = sprintf(
'uploads/direct/%s/%s.%s',
now()->format('Y/m'),
Str::uuid(),
$extension
);
$client = Storage::disk('s3')->getClient();
$command = $client->getCommand('PutObject', [
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => $key,
'ContentType' => $request->content_type,
]);
$presignedUrl = (string) $client->createPresignedRequest($command, '+15 minutes')->getUri();
return response()->json([
'url' => $presignedUrl,
'key' => $key,
]);
}
}
Frontend tarafinda:
async function directUpload(file) {
// 1. Presigned URL al
const { data } = await fetch('/api/uploads/presign', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
filename: file.name,
content_type: file.type,
size: file.size,
}),
}).then(r => r.json());
// 2. Direkt S3'e yukle
await fetch(data.url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});
// 3. Backend'e yuklendigini bildir
await fetch('/api/uploads/confirm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ key: data.key, original_name: file.name }),
});
}
Test
Laravel'in Storage::fake() ile S3 testleri yazmak cok kolay:
// tests/Feature/UploadTest.php
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class UploadTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_upload_file(): void
{
Storage::fake('s3');
$user = User::factory()->create();
$file = UploadedFile::fake()->image('avatar.jpg', 800, 600)->size(500);
$response = $this->actingAs($user)
->postJson('/api/uploads', [
'files' => [$file],
'collection' => 'avatars',
]);
$response->assertOk()
->assertJsonCount(1, 'uploads')
->assertJsonStructure([
'uploads' => [['id', 'name', 'size', 'url', 'mime_type']],
]);
// S3'te dosyanin var oldugunu kontrol et
Storage::disk('s3')->assertExists(
$response->json('uploads.0.url') // path donen alan
);
$this->assertDatabaseHas('uploads', [
'user_id' => $user->id,
'collection' => 'avatars',
'original_name' => 'avatar.jpg',
]);
}
public function test_duplicate_file_reuses_path(): void
{
Storage::fake('s3');
$user = User::factory()->create();
$file1 = UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf');
// Ayni dosyayi iki kere yukle
$this->actingAs($user)->postJson('/api/uploads', ['files' => [$file1]]);
$file2 = UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf');
$this->actingAs($user)->postJson('/api/uploads', ['files' => [$file2]]);
// Veritabaninda 2 kayit olmali
$this->assertDatabaseCount('uploads', 2);
}
public function test_upload_rejects_invalid_file_type(): void
{
Storage::fake('s3');
$user = User::factory()->create();
$file = UploadedFile::fake()->create('malware.exe', 100);
$response = $this->actingAs($user)
->postJson('/api/uploads', ['files' => [$file]]);
$response->assertUnprocessable();
}
public function test_upload_rejects_oversized_file(): void
{
Storage::fake('s3');
$user = User::factory()->create();
$file = UploadedFile::fake()->create('huge.pdf', 20000); // 20MB
$response = $this->actingAs($user)
->postJson('/api/uploads', ['files' => [$file]]);
$response->assertUnprocessable();
}
public function test_guest_cannot_upload(): void
{
Storage::fake('s3');
$file = UploadedFile::fake()->image('test.jpg');
$response = $this->postJson('/api/uploads', ['files' => [$file]]);
$response->assertUnauthorized();
}
}
Sonuc
Dosya yukleme sistemi kurarken en onemli uc sey: guvenlik, deduplication ve olceklenebilirlik. S3 ile storage sorununu cozuyorsunuz, hash ile gereksiz kopyalamayi onluyorsunuz, presigned URL ile sunucu yukunu azaltiyorsunuz.
Kucuk projeler icin bu yapiyi oversized bulabilirsiniz ama projeniz buyudugunde bu altyapiyi kurmadiginiz icin pisman olursunuz. En azindan Service katmanini ve hash-based deduplication'i bastan koymanizi oneririm. Geri kalanini ihtiyaca gore ekleyin.