تخزين الملفات والتحميلات في Laravel
نظام الملفات هو "إدارة المستودعات" في Laravel — سواء كانت الملفات مخزنة على قرص محلي أو في سحابة S3، يبقى الكود كما هو تمامًا.
1. ما ستتعلمه
- طبقة تجريد FileSystem: تكوين مشغلات local/public/s3
- عملية تحميل الملفات الكاملة: التحقق → التخزين → توليد URL → الاستجابة
- رابط تخزين رمزي: php artisan storage:link
- تكامل تخزين S3 السحابي وعناوين URL الموقعة مسبقًا
- عمليات الملفات: نسخ/نقل/حذف/رؤية ومعالجة التدفق
2. قصة حقيقية من عالم العمليات
(1) مشكلة: قرص الخادم ممتلئ، وجميع الصور فقدت
كانت جميع صور منتجات ShopMetrics مخزنة محليًا على الخادم في دليل public/uploads/ — 500 غيغابايت من الصور ملأت القرص، مما تسبب في تعطل الموقع. لجعل الأمور أسوأ، لم تكن هناك نسخ احتياطية بعد فشل أجهزة الخادم، وفقدت Alice جميع صور منتجاتها الـ 2000. أراد Bob الانتقال إلى S3، لكن مسارات الملفات كانت مشفرة بشكل ثابت في جميع أنحاء الكود، وبعد ثلاثة أيام من العمل، لم ينتهِ من إجراء التغييرات.
(2) حل طبقة تجريد التخزين
يستخدم Laravel Storage facade واجهة برمجة تطبيقات موحدة لإدارة الملفات — سواء محليًا أو S3 أو أي مشغل آخر، يبقى الكود كما هو؛ الترحيل يتطلب ببساطة تغيير تكوين .env.
// نفس الكود يعمل مع local أو S3 أو أي مشغل
Storage::disk('public')->put('shops/logo.jpg', $file);
$url = Storage::disk('public')->url('shops/logo.jpg');
// التبديل إلى S3 بتغيير .env
// FILESYSTEM_DISK=s3
// كل شيء آخر يبقى كما هو!
(3) العائد
حول Bob إلى S3 بتغيير سطرين فقط من .env، بدون تعديلات على الكود. صور Alice على S3 لها متانة 11 تسعة، لذا نفاد مساحة القرص وأعطال الأجهزة لم تعد مشكلة.
3. طبقة تجريد التخزين
(1) تكوين المشغلات
// config/filesystems.php
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'throw' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL') . '/storage',
'visibility' => 'public',
],
'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'),
],
],
'default' => env('FILESYSTEM_DISK', 'local'),
(2) مقارنة المشغلات
| المشغل | موقع التخزين | وصول الشبكة العامة | المتانة | التكلفة |
|---|---|---|---|---|
local |
storage/app/ | ❌ يتطلب مسارًا | الخادم | مجاني |
public |
storage/app/public/ | ✅ رابط رمزي | الخادم | مجاني |
s3 |
AWS S3 | ✅ URL | 11 تسعة | الدفع حسب الاستخدام |
s3+CDN |
S3 + CloudFront | ✅ CDN | 11 تسعة | الدفع حسب الاستخدام |
(1) ▶ مثال:تكوين تخزين ShopMetrics
# .env — التطوير: استخدام القرص public
FILESYSTEM_DISK=public
# .env — الإنتاج: استخدام S3
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=shopmetrics-uploads
# إنشاء رابط رمزي للقرص public
php artisan storage:link
# [OK] Link created: public/storage -> storage/app/public
الناتج:
# تم تنفيذ الأمر بنجاح
4. عملية تحميل الملفات الكاملة
(1) التحقق → التخزين → توليد URL
// الخطوة 1: التحقق
$validated = $request->validate([
'logo' => 'required|image|mimes:jpeg,png,webp|max:2048',
]);
// الخطوة 2: التخزين
$path = $request->file('logo')->store('shops/logos', 'public');
// => "shops/logos/abc123def456.jpg"
// الخطوة 3: توليد URL
$url = Storage::disk('public')->url($path);
// => "http://shopmetrics.test/storage/shops/logos/abc123def456.jpg"
// الخطوة 4: حفظ المسار في قاعدة البيانات
$shop->update(['logo_path' => $path]);
(2) مقارنة طرق التحميل
| الطريقة | اسم الملف | مسار مثال |
|---|---|---|
store('dir', 'disk') |
اسم تجزئة عشوائي | shops/logos/abc123.jpg |
storeAs('dir', name, 'disk') |
اسم مخصص | shops/logos/alice-store.jpg |
storePublicly('dir', 'disk') |
اسم عشوائي + عام | نفس store |
storePubliclyAs(...) |
اسم مخصص + عام | نفس storeAs |
(1) ▶ مثال:تحميل صور منتجات ShopMetrics
// app/Http/Controllers/ProductImageController.php
class ProductImageController extends Controller
{
public function store(Request $request, Product $product): JsonResponse
{
$validated = $request->validate([
'image' => 'required|image|mimes:jpeg,png,webp|max:5120',
'is_primary' => 'sometimes|boolean',
]);
$path = $request->file('image')->store(
"products/{$product->id}/images",
's3',
);
$image = $product->images()->create([
'path' => $path,
'is_primary' => $validated['is_primary'] ?? false,
'mime_type' => $request->file('image')->getMimeType(),
'size' => $request->file('image')->getSize(),
]);
return response()->json([
'message' => 'Image uploaded.',
'data' => [
'id' => $image->id,
'url' => Storage::disk('s3')->url($path),
],
], 201);
}
public function destroy(Product $product, Image $image): Response
{
Storage::disk('s3')->delete($image->path);
$image->delete();
return response()->noContent();
}
}
الناتج:
// التنفيذ ناجح
5. الروابط الرمزية والأقراص العامة
(1) إنشاء رابط رمزي
php artisan storage:link
# ينشئ: public/storage → storage/app/public
(2) كيف تعمل الروابط الرمزية
public/
├── index.php
├── storage/ → ../../storage/app/public/ (رابط رمزي)
│ └── shops/logos/abc123.jpg (قابل للوصول عبر الويب)
storage/
└── app/
└── public/ (موقع الملف الفعلي)
└── shops/logos/abc123.jpg
| المسار | الغرض | وصول الويب |
|---|---|---|
storage/app/public/ |
تخزين الملفات العامة | ✅ عبر الروابط الرمزية |
storage/app/ |
تخزين الملفات الخاصة | ❌ ليس عبر الويب |
public/ |
الدليل الجذر للويب | ✅ وصول مباشر |
(1) ▶ مثال:تنزيل الملفات الخاصة
// ملف خاص — غير قابل للوصول عبر الويب، يجب المرور عبر وحدة التحكم
Route::get('/reports/{report}/download', [ReportController::class, 'download'])
->middleware('auth');
class ReportController extends Controller
{
public function download(Report $report): StreamedResponse
{
$this->authorize('view', $report);
if (!Storage::disk('local')->exists($report->file_path)) {
abort(404, 'Report file not found.');
}
return Storage::disk('local')->download(
$report->file_path,
"report-{$report->id}.pdf",
);
}
}
الناتج:
// التنفيذ ناجح
6. تخزين S3 السحابي وعناوين URL الموقعة مسبقًا
(1) تكامل S3
composer require league/flysystem-aws-s3-v3:"^3.0"
(2) عنوان URL الموقع مسبقًا
تسمح عناوين URL الموقعة مسبقًا للعملاء بتحميل وتنزيل ملفات S3 مباشرة، دون المرور عبر خادم — مما يوفر عرض النطاق الترددي ويقلل من حمل الخادم.
// توليد URL تحميل مؤقت (العميل يرفع مباشرة إلى S3)
$uploadUrl = Storage::disk('s3')->temporaryUploadUrl(
"products/{$product->id}/images/" . $request->filename,
now()->addMinutes(30),
['ContentType' => $request->mime_type],
);
// توليد URL تنزيل مؤقت
$downloadUrl = Storage::disk('s3')->temporaryUrl(
$image->path,
now()->addMinutes(15),
);
(3) S3 مقابل المحلي
| البُعد | القرص العام | S3 |
|---|---|---|
| تخزين الملفات | قرص الخادم | AWS S3 |
| وصول الويب | روابط رمزية | URL/CDN |
| التوسع | محدود بالقرص | غير محدود |
| التوفر | يعتمد على الخادم | 99.999999999% |
| تكامل CDN | يتطلب تكوين | CloudFront |
| URL موقع مسبقًا | ❌ | ✅ |
| التكلفة | مجاني | الدفع حسب الاستخدام |
(1) ▶ مثال:تحميل S3 الموقع مسبقًا لـ ShopMetrics
// app/Http/Controllers/Api/FileUploadController.php
class FileUploadController extends Controller
{
public function presign(Request $request): JsonResponse
{
$validated = $request->validate([
'filename' => 'required|string',
'mime_type' => 'required|string|in:image/jpeg,image/png,image/webp',
'size' => 'required|integer|max:5120',
]);
$path = 'uploads/' . auth()->id() . '/' . Str::uuid() . '/' . $validated['filename'];
$uploadUrl = Storage::disk('s3')->temporaryUploadUrl(
$path,
now()->addMinutes(30),
['ContentType' => $validated['mime_type']],
);
return response()->json([
'upload_url' => $uploadUrl,
'path' => $path,
'expires_in' => 1800,
]);
}
public function confirm(Request $request): JsonResponse
{
$validated = $request->validate([
'path' => 'required|string',
'attachable_type' => 'required|string',
'attachable_id' => 'required|integer',
]);
$url = Storage::disk('s3')->url($validated['path']);
return response()->json([
'url' => $url,
'path' => $validated['path'],
]);
}
}
الناتج:
// التنفيذ ناجح
7. عمليات الملفات
(1) العمليات الشائعة
// قراءة
$content = Storage::disk('s3')->get('shops/logos/abc.jpg');
$exists = Storage::disk('s3')->exists('shops/logos/abc.jpg');
$missing = Storage::disk('s3')->missing('shops/logos/abc.jpg');
// كتابة
Storage::disk('s3')->put('reports/summary.csv', $csvContent);
Storage::disk('s3')->putFileAs('avatars', $uploadedFile, 'profile.jpg');
// نسخ / نقل
Storage::disk('s3')->copy('old/path.jpg', 'new/path.jpg');
Storage::disk('s3')->move('temp/file.jpg', 'permanent/file.jpg');
// حذف
Storage::disk('s3')->delete('shops/logos/abc.jpg');
Storage::disk('s3')->delete(['file1.jpg', 'file2.jpg']);
// الرؤية
Storage::disk('s3')->setVisibility('file.jpg', 'public');
Storage::disk('s3')->setVisibility('file.jpg', 'private');
$visibility = Storage::disk('s3')->getVisibility('file.jpg');
// الدليل
$files = Storage::disk('s3')->files('shops/logos');
$allFiles = Storage::disk('s3')->allFiles('shops');
$directories = Storage::disk('s3')->directories('shops');
Storage::disk('s3')->makeDirectory('shops/new-dir');
Storage::disk('s3')->deleteDirectory('shops/old-dir');
// بيانات الملف الوصفية
$size = Storage::disk('s3')->size('file.jpg');
$modified = Storage::disk('s3')->lastModified('file.jpg');
$path = Storage::disk('s3')->path('file.jpg');
(1) ▶ مثال:توليد وتخزين تقارير ShopMetrics
// app/Services/ReportService.php
class ReportService
{
public function generateOrderReport(Tenant $tenant, string $format = 'csv'): string
{
$orders = Order::where('tenant_id', $tenant->id)
->with(['shop', 'items.product'])
->completed()
->latest()
->get();
$csv = "Order Number,Shop,Customer,Total,Status,Date\n";
foreach ($orders as $order) {
$csv .= implode(',', [
$order->order_number,
$order->shop->name,
$order->user->name,
$order->total,
$order->status,
$order->created_at->format('Y-m-d'),
]) . "\n";
}
$path = "reports/{$tenant->slug}/orders-" . now()->format('Y-m-d-His') . ".csv";
Storage::disk('s3')->put($path, $csv);
return $path;
}
public function getDownloadUrl(string $path, int $expiresMinutes = 15): string
{
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresMinutes));
}
public function cleanupOldReports(Tenant $tenant): int
{
$cutoff = now()->subDays(90)->format('Y-m-d');
$files = Storage::disk('s3')->allFiles("reports/{$tenant->slug}");
$deleted = 0;
foreach ($files as $file) {
if (str_contains($file, $cutoff) || Storage::disk('s3')->lastModified($file) < now()->subDays(90)->timestamp) {
Storage::disk('s3')->delete($file);
$deleted++;
}
}
return $deleted;
}
}
الناتج:
// التنفيذ ناجح
8. مثال شامل: نظام تحميل ملفات ShopMetrics
// ============================================
// شامل: نظام تحميل ملفات ShopMetrics
// يغطي: التحميل، S3، عناوين URL الموقعة مسبقًا، التنظيف، التدفق
// ============================================
// app/Http/Controllers/Api/MediaController.php
class MediaController extends Controller
{
public function upload(Request $request): JsonResponse
{
$validated = $request->validate([
'file' => 'required|file|max:10240',
'collection' => 'required|in:logos,products,reports',
'attachable_type' => 'sometimes|string',
'attachable_id' => 'sometimes|integer',
]);
$file = $request->file('file');
$tenantId = tenant()->id;
$path = $validated['collection'] . "/{$tenantId}/" . Str::uuid() . '.' . $file->extension();
$stored = Storage::disk('s3')->put($path, $file->getContent(), 'public');
if (!$stored) {
return response()->json(['message' => 'Upload failed.'], 500);
}
$media = Media::create([
'tenant_id' => $tenantId,
'path' => $path,
'filename' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'collection' => $validated['collection'],
]);
return response()->json([
'message' => 'File uploaded.',
'data' => [
'id' => $media->id,
'url' => Storage::disk('s3')->url($path),
'filename' => $media->filename,
'size' => $media->size,
],
], 201);
}
public function download(Media $media): StreamedResponse
{
$this->authorize('view', $media);
if ($media->isPublic()) {
return redirect(Storage::disk('s3')->url($media->path));
}
return Storage::disk('s3')->download($media->path, $media->filename);
}
public function temporaryUrl(Media $media): JsonResponse
{
$this->authorize('view', $media);
$url = Storage::disk('s3')->temporaryUrl(
$media->path,
now()->addMinutes(15),
);
return response()->json(['url' => $url, 'expires_in' => 900]);
}
public function destroy(Media $media): Response
{
$this->authorize('delete', $media);
Storage::disk('s3')->delete($media->path);
$media->delete();
return response()->noContent();
}
}
❓ أسئلة شائعة
storage:link على Windows؟php artisan storage:link في Laravel ينشئ روابط رمزية تلقائيًا على Windows، لكن يتطلب امتيازات المسؤول. إذا فشل ذلك، أنشئه يدويًا: mklink /D public\storage storage\app\public.upload_max_filesize وpost_max_size.AWS_URL باسم نطاق CloudFront الخاص بك. جميع عناوين URL التي تعيدها Storage::url() ستتوجه تلقائيًا عبر CDN، مما يسرع الوصول من جميع أنحاء العالم.deleting في boot() النموذج لحذف الملفات المرتبطة؛ شغّل أوامر Artisan بشكل دوري لتنظيف الملفات اليتيمة؛ استخدم سياسات دورة حياة S3 لانتهاء صلاحية الملفات القديمة تلقائيًا.FILESYSTEM_DISK في .env دون تعديل الكود.📖 ملخص
- طبقة تجريد Storage توفر واجهة برمجة تطبيقات موحدة؛ لتبديل المشغلات، عدّل ملف .env ببساطة
- الأقراص العامة تتطلب رابطًا رمزيًا
storage:linkلتكون قابلة للوصول عبر الويب - تحميل الملفات: التحقق → store() → حفظ المسار في قاعدة البيانات → توليد URL
- S3 مناسب لبيئات الإنتاج: متانة عالية، توسع غير محدود، وتكامل CDN
- عناوين URL الموقعة مسبقًا تسمح للعملاء بالتحميل مباشرة إلى S3، مما يوفر عرض نطاق الخادم
- الملفات الخاصة تُنزّل عبر وحدة التحكم، بينما الملفات العامة يمكن الوصول إليها مباشرة عبر URL
📝 تمارين
-
تمرين أساسي (⭐): قم بتكوين ShopMetrics لاستخدام تخزين القرص العام، ونفذ تحميل صور المنتجات (التحقق والتخزين والعرض)، واعرض الصور المحملة على صفحة Blade.
-
تمرين متقدم (⭐⭐): حوّل إلى تخزين S3 ونفذ التحميل عبر عناوين URL الموقعة مسبقًا — الواجهة الأمامية تحصل على عنوان URL الموقع مسبقًا وتحمل مباشرة إلى S3، والواجهة الخلفية تنشئ سجل Media بعد التحقق.
-
تحدي (⭐⭐⭐): نفّذ إدارة دورة حياة ملفات S3 — عيّن وسمًا (tenant_id) عند التحميل، واكتب أمر Artisan لتنظيف ملفات التقارير الأقدم من 90 يومًا حسب المستأجر، واستخدم واجهة برمجة تطبيقات الحذف الجماعي لـ S3 لتحسين الأداء.



