MidПрактика7 min

Файловое хранилище

Filesystem диски (local, s3, sftp), загрузка файлов, URL файлов, кастомные файловые системы, временные URL в Laravel 11

Файловое хранилище (File Storage)

Laravel предоставляет абстракцию файловой системы через пакет Flysystem, поддерживая локальное хранилище, Amazon S3, SFTP и другие драйверы с единым API.

Конфигурация дисков

// config/filesystems.php
return [
    'default' => env('FILESYSTEM_DISK', 'local'),

    'disks' => [
        // Local disk - private files (storage/app/private)
        'local' => [
            'driver' => 'local',
            'root' => storage_path('app/private'),
            'serve' => true,
            'throw' => false,
        ],

        // Public disk - publicly accessible (storage/app/public)
        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL') . '/storage',
            'visibility' => 'public',
            'throw' => false,
        ],

        // Amazon S3
        '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' => false,
        ],

        // SFTP
        'sftp' => [
            'driver' => 'sftp',
            'host' => env('SFTP_HOST'),
            'username' => env('SFTP_USERNAME'),
            'password' => env('SFTP_PASSWORD'),
            'privateKey' => env('SFTP_PRIVATE_KEY'),
            'passphrase' => env('SFTP_PASSPHRASE'),
            'root' => env('SFTP_ROOT', '/'),
            'timeout' => 30,
        ],
    ],

    'links' => [
        public_path('storage') => storage_path('app/public'),
    ],
];

Создание символической ссылки

php artisan storage:link
# Creates: public/storage → storage/app/public

Основные операции

Фасад Storage

use Illuminate\Support\Facades\Storage;

// Default disk
Storage::put('file.txt', 'Contents');

// Specific disk
Storage::disk('s3')->put('file.txt', 'Contents');

// Switch disk dynamically
$disk = Storage::disk($user->preferredStorage());

Чтение файлов

// Get file contents as string
$contents = Storage::get('documents/report.pdf');

// Check if file exists
if (Storage::exists('photos/avatar.jpg')) {
    // File exists
}

// Check if file is missing
if (Storage::missing('photos/avatar.jpg')) {
    // File does not exist
}

// Get file metadata
$size = Storage::size('documents/report.pdf');       // Size in bytes
$lastModified = Storage::lastModified('file.txt');   // Unix timestamp
$mimeType = Storage::mimeType('photos/avatar.jpg');  // image/jpeg

// Get all files in directory
$files = Storage::files('photos');           // Only top-level
$allFiles = Storage::allFiles('photos');     // Recursive

// Get all directories
$dirs = Storage::directories('uploads');
$allDirs = Storage::allDirectories('uploads');  // Recursive

Запись файлов

// Write string content
Storage::put('file.txt', 'File contents here');

// Write with visibility
Storage::put('file.txt', 'Content', 'public');

// Prepend/Append
Storage::prepend('log.txt', 'First line');
Storage::append('log.txt', 'New line at end');

// Store with auto-generated filename
$path = Storage::putFile('photos', $request->file('avatar'));
// Returns: photos/abc123def456.jpg

// Store with custom filename
$path = Storage::putFileAs('photos', $request->file('avatar'), 'user-avatar.jpg');
// Returns: photos/user-avatar.jpg

// Stream large files (memory efficient)
Storage::writeStream('large-file.zip', fopen('/local/path/large-file.zip', 'r'));
$stream = Storage::readStream('large-file.zip');

Копирование и перемещение

Storage::copy('old/file.jpg', 'new/file.jpg');
Storage::move('old/file.jpg', 'new/file.jpg');

Удаление файлов

// Delete single file
Storage::delete('file.txt');

// Delete multiple files
Storage::delete(['file1.txt', 'file2.txt', 'file3.txt']);

// Delete directory
Storage::deleteDirectory('uploads/temp');

// Create directory
Storage::makeDirectory('uploads/images');

Загрузка файлов (File Uploads)

Из HTTP-запроса

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage;

final class AvatarController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'avatar' => ['required', 'image', 'max:2048', 'dimensions:min_width=100,min_height=100'],
        ]);

        // Store with auto-generated name
        $path = $request->file('avatar')->store('avatars', 'public');

        // Store with custom name
        $path = $request->file('avatar')->storeAs(
            'avatars',
            "user-{$request->user()->id}.jpg",
            'public'
        );

        // Update user record
        $request->user()->update(['avatar_path' => $path]);

        return redirect()->back()->with('status', 'Avatar updated!');
    }
}

Множественная загрузка

public function uploadPhotos(Request $request): RedirectResponse
{
    $request->validate([
        'photos' => ['required', 'array', 'max:10'],
        'photos.*' => ['image', 'max:5120'],
    ]);

    $paths = [];

    foreach ($request->file('photos') as $photo) {
        $paths[] = $photo->store('gallery/' . auth()->id(), 'public');
    }

    // Save paths to database
    foreach ($paths as $path) {
        Photo::create([
            'user_id' => auth()->id(),
            'path' => $path,
        ]);
    }

    return redirect()->back()->with('status', count($paths) . ' photos uploaded!');
}

Информация о загруженном файле

$file = $request->file('document');

$file->getClientOriginalName();       // Original filename
$file->getClientOriginalExtension();  // Original extension
$file->getClientMimeType();           // MIME type from client
$file->getMimeType();                 // Actual MIME type (detected)
$file->getSize();                     // Size in bytes
$file->extension();                   // Guessed extension from MIME
$file->hashName();                    // Auto-generated hash name
$file->path();                        // Temp path on server
$file->isValid();                     // Upload was successful

URL файлов

Публичные URL

// For 'public' disk
$url = Storage::disk('public')->url('avatars/user-1.jpg');
// https://example.com/storage/avatars/user-1.jpg

// For S3
$url = Storage::disk('s3')->url('photos/image.jpg');
// https://bucket.s3.amazonaws.com/photos/image.jpg

Временные URL (Temporary URLs)

Временные URL с ограниченным сроком действия. Доступны для S3 и других облачных драйверов:

// Generate temporary URL (expires in 5 minutes)
$url = Storage::disk('s3')->temporaryUrl(
    'reports/confidential-report.pdf',
    now()->addMinutes(5)
);

// Temporary URL with custom headers
$url = Storage::disk('s3')->temporaryUrl(
    'documents/contract.pdf',
    now()->addHour(),
    [
        'ResponseContentType' => 'application/octet-stream',
        'ResponseContentDisposition' => 'attachment; filename="contract.pdf"',
    ]
);

Temporary URL для локального диска

В Laravel 11 можно генерировать временные URL для локальных файлов:

// config/filesystems.php
'local' => [
    'driver' => 'local',
    'root' => storage_path('app/private'),
    'serve' => true, // Enable serving via route
    'throw' => false,
],
// Generates signed URL for local files
$url = Storage::disk('local')->temporaryUrl(
    'private/invoice.pdf',
    now()->addMinutes(30)
);

Видимость файлов (Visibility)

// Set visibility when storing
Storage::put('file.txt', 'Content', 'public');

// Change visibility
Storage::setVisibility('file.txt', 'public');
Storage::setVisibility('file.txt', 'private');

// Get visibility
$visibility = Storage::getVisibility('file.txt');
// 'public' or 'private'

Скачивание и Streaming

// Download response
return Storage::download('reports/monthly.pdf');
return Storage::download('reports/monthly.pdf', 'Custom-Name.pdf', [
    'Content-Type' => 'application/pdf',
]);

// Stream response (for large files)
return Storage::response('videos/tutorial.mp4');

Практический пример: сервис управления файлами

declare(strict_types=1);

namespace App\Services;

use App\Models\Attachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

final class FileService
{
    private const MAX_SIZE_MB = 50;
    private const ALLOWED_MIMES = [
        'image/jpeg', 'image/png', 'image/webp',
        'application/pdf',
        'application/msword',
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    ];

    public function upload(UploadedFile $file, string $directory, string $disk = 'public'): Attachment
    {
        $this->validateFile($file);

        $filename = $this->generateFilename($file);
        $path = $file->storeAs($directory, $filename, $disk);

        return Attachment::create([
            'disk' => $disk,
            'path' => $path,
            'filename' => $file->getClientOriginalName(),
            'mime_type' => $file->getMimeType(),
            'size' => $file->getSize(),
            'hash' => hash_file('sha256', $file->path()),
        ]);
    }

    public function delete(Attachment $attachment): bool
    {
        if (Storage::disk($attachment->disk)->exists($attachment->path)) {
            Storage::disk($attachment->disk)->delete($attachment->path);
        }

        return $attachment->delete();
    }

    public function getUrl(Attachment $attachment, int $expiresMinutes = 60): string
    {
        $disk = Storage::disk($attachment->disk);

        if ($attachment->disk === 's3') {
            return $disk->temporaryUrl($attachment->path, now()->addMinutes($expiresMinutes));
        }

        if ($attachment->disk === 'public') {
            return $disk->url($attachment->path);
        }

        return $disk->temporaryUrl($attachment->path, now()->addMinutes($expiresMinutes));
    }

    private function validateFile(UploadedFile $file): void
    {
        if (!$file->isValid()) {
            throw new \RuntimeException('File upload failed.');
        }

        if (!in_array($file->getMimeType(), self::ALLOWED_MIMES, true)) {
            throw new \RuntimeException('File type not allowed: ' . $file->getMimeType());
        }

        if ($file->getSize() > self::MAX_SIZE_MB * 1024 * 1024) {
            throw new \RuntimeException('File size exceeds ' . self::MAX_SIZE_MB . 'MB limit.');
        }
    }

    private function generateFilename(UploadedFile $file): string
    {
        return Str::uuid()->toString() . '.' . $file->extension();
    }
}

Кастомные файловые системы

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\ServiceProvider;
use League\Flysystem\Filesystem;

final class FilesystemServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Storage::extend('dropbox', function ($app, $config) {
            $adapter = new DropboxAdapter(
                new DropboxClient($config['authorization_token'])
            );

            return new FilesystemAdapter(
                new Filesystem($adapter, $config),
                $adapter,
                $config
            );
        });
    }
}
// config/filesystems.php
'disks' => [
    'dropbox' => [
        'driver' => 'dropbox',
        'authorization_token' => env('DROPBOX_TOKEN'),
    ],
],

Тестирование файлового хранилища

declare(strict_types=1);

namespace Tests\Feature;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

final class AvatarUploadTest extends TestCase
{
    public function test_avatar_can_be_uploaded(): void
    {
        Storage::fake('public');

        $file = UploadedFile::fake()->image('avatar.jpg', 200, 200);

        $response = $this->actingAs(User::factory()->create())
            ->post('/avatar', ['avatar' => $file]);

        $response->assertRedirect();

        // Assert file was stored
        Storage::disk('public')->assertExists('avatars/' . $file->hashName());
    }

    public function test_non_image_file_rejected(): void
    {
        Storage::fake('public');

        $file = UploadedFile::fake()->create('document.pdf', 1024, 'application/pdf');

        $response = $this->actingAs(User::factory()->create())
            ->post('/avatar', ['avatar' => $file]);

        $response->assertSessionHasErrors('avatar');
        Storage::disk('public')->assertMissing('avatars/' . $file->hashName());
    }

    public function test_oversized_file_rejected(): void
    {
        Storage::fake('public');

        // Create 5MB image (exceeds 2MB limit)
        $file = UploadedFile::fake()->image('avatar.jpg')->size(5120);

        $response = $this->actingAs(User::factory()->create())
            ->post('/avatar', ['avatar' => $file]);

        $response->assertSessionHasErrors('avatar');
    }

    public function test_multiple_photos_uploaded(): void
    {
        Storage::fake('public');

        $files = [
            UploadedFile::fake()->image('photo1.jpg'),
            UploadedFile::fake()->image('photo2.jpg'),
            UploadedFile::fake()->image('photo3.jpg'),
        ];

        $response = $this->actingAs($user = User::factory()->create())
            ->post('/photos', ['photos' => $files]);

        $response->assertRedirect();

        // Assert all files stored
        foreach ($files as $file) {
            Storage::disk('public')->assertExists('gallery/' . $user->id . '/' . $file->hashName());
        }
    }
}

::alert{type="info"} Storage::fake() создаёт виртуальную файловую систему в памяти. Файлы не записываются на диск. Это идеально для тестирования - быстро, безопасно, не оставляет мусора. ::


Проверь себя

Что делает команда php artisan storage:link?

Что делает Storage::fake() в тестах?

Для каких дисков доступны временные URL (temporaryUrl)?