404 Not Found

404 Not Found


nginx

تخزين الملفات والتحميلات في Laravel

نظام الملفات هو "إدارة المستودعات" في Laravel — سواء كانت الملفات مخزنة على قرص محلي أو في سحابة S3، يبقى الكود كما هو تمامًا.

1. ما ستتعلمه


2. قصة حقيقية من عالم العمليات

(1) مشكلة: قرص الخادم ممتلئ، وجميع الصور فقدت

كانت جميع صور منتجات ShopMetrics مخزنة محليًا على الخادم في دليل public/uploads/ — 500 غيغابايت من الصور ملأت القرص، مما تسبب في تعطل الموقع. لجعل الأمور أسوأ، لم تكن هناك نسخ احتياطية بعد فشل أجهزة الخادم، وفقدت Alice جميع صور منتجاتها الـ 2000. أراد Bob الانتقال إلى S3، لكن مسارات الملفات كانت مشفرة بشكل ثابت في جميع أنحاء الكود، وبعد ثلاثة أيام من العمل، لم ينتهِ من إجراء التغييرات.

(2) حل طبقة تجريد التخزين

يستخدم Laravel Storage facade واجهة برمجة تطبيقات موحدة لإدارة الملفات — سواء محليًا أو S3 أو أي مشغل آخر، يبقى الكود كما هو؛ الترحيل يتطلب ببساطة تغيير تكوين .env.

PHP
// نفس الكود يعمل مع 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) تكوين المشغلات

PHP
// 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

BASH
# .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

الناتج:

TEXT
# تم تنفيذ الأمر بنجاح

4. عملية تحميل الملفات الكاملة

(1) التحقق → التخزين → توليد URL

PHP
// الخطوة 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

PHP
// 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();
    }
}

الناتج:

TEXT
// التنفيذ ناجح

5. الروابط الرمزية والأقراص العامة

(1) إنشاء رابط رمزي

BASH
php artisan storage:link
# ينشئ: public/storage → storage/app/public

(2) كيف تعمل الروابط الرمزية

TEXT
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) ▶ مثال:تنزيل الملفات الخاصة

PHP
// ملف خاص — غير قابل للوصول عبر الويب، يجب المرور عبر وحدة التحكم
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",
        );
    }
}

الناتج:

TEXT
// التنفيذ ناجح

6. تخزين S3 السحابي وعناوين URL الموقعة مسبقًا

(1) تكامل S3

BASH
composer require league/flysystem-aws-s3-v3:"^3.0"

(2) عنوان URL الموقع مسبقًا

تسمح عناوين URL الموقعة مسبقًا للعملاء بتحميل وتنزيل ملفات S3 مباشرة، دون المرور عبر خادم — مما يوفر عرض النطاق الترددي ويقلل من حمل الخادم.

PHP
// توليد 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

PHP
// 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'],
        ]);
    }
}

الناتج:

TEXT
// التنفيذ ناجح

7. عمليات الملفات

(1) العمليات الشائعة

PHP
// قراءة
$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

PHP
// 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;
    }
}

الناتج:

TEXT
// التنفيذ ناجح

8. مثال شامل: نظام تحميل ملفات ShopMetrics

PHP
// ============================================
// شامل: نظام تحميل ملفات 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.
س متى يجب استخدام عنوان URL موقع مسبقًا؟
ج استخدم عنوان URL موقع مسبقًا عندما يحتاج العميل (المتصفح/الجهاز المحمول) إلى تحميل ملف مباشرة إلى S3 لتجنب توجيه الملف عبر خادم. الملفات الكبيرة (>10 ميغابايت) يجب تحميلها باستخدام عنوان URL موقع مسبقًا.
س كيف أتعامل مع تحميل الملفات الكبيرة؟
ج استخدم عنوان URL موقع مسبقًا للسماح للعملاء بالتحميل مباشرة إلى S3؛ أو استخدم التحميل متعدد الأجزاء، الذي يدعم الملفات بحجم الجيغابايت. على جانب PHP، ستحتاج إلى تعديل upload_max_filesize وpost_max_size.
س كيف يعمل S3 وCloudFront CDN معًا؟
ج في AWS، قم بتكوين توزيعة CloudFront للإشارة إلى حاوية S3، واستبدل AWS_URL باسم نطاق CloudFront الخاص بك. جميع عناوين URL التي تعيدها Storage::url() ستتوجه تلقائيًا عبر CDN، مما يسرع الوصول من جميع أنحاء العالم.
س كيف أنظف الملفات غير المستخدمة؟
ج عند حذف نموذج، استمع لحدث deleting في boot() النموذج لحذف الملفات المرتبطة؛ شغّل أوامر Artisan بشكل دوري لتنظيف الملفات اليتيمة؛ استخدم سياسات دورة حياة S3 لانتهاء صلاحية الملفات القديمة تلقائيًا.
س هل يمكنني استخدام القرص العام وS3 في نفس الوقت؟
ج نعم. استخدم القرص العام للتطوير (مجاني وسريع) وS3 للإنتاج (موثوق وقابل للتوسع). يمكنك تبديل القرص الافتراضي باستخدام FILESYSTEM_DISK في .env دون تعديل الكود.

📖 ملخص


📝 تمارين

  1. تمرين أساسي (⭐): قم بتكوين ShopMetrics لاستخدام تخزين القرص العام، ونفذ تحميل صور المنتجات (التحقق والتخزين والعرض)، واعرض الصور المحملة على صفحة Blade.

  2. تمرين متقدم (⭐⭐): حوّل إلى تخزين S3 ونفذ التحميل عبر عناوين URL الموقعة مسبقًا — الواجهة الأمامية تحصل على عنوان URL الموقع مسبقًا وتحمل مباشرة إلى S3، والواجهة الخلفية تنشئ سجل Media بعد التحقق.

  3. تحدي (⭐⭐⭐): نفّذ إدارة دورة حياة ملفات S3 — عيّن وسمًا (tenant_id) عند التحميل، واكتب أمر Artisan لتنظيف ملفات التقارير الأقدم من 90 يومًا حسب المستأجر، واستخدم واجهة برمجة تطبيقات الحذف الجماعي لـ S3 لتحسين الأداء.

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%