Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Backend Features/File Storage

File Storage Guide

Last Updated: 2026-01-08 Status: Active Audience: Developers

This guide documents file storage patterns and best practices for the Client Portal application.


Table of Contents

  1. Overview
  2. Storage Configuration
  3. File Uploads
  4. File Management
  5. Secure Downloads
  6. Image Handling
  7. Testing

Overview

Laravel's filesystem abstraction provides a unified API for:

  • Local file storage
  • Cloud storage (S3, GCS, etc.)
  • FTP/SFTP servers

Storage Disks

DiskUse Case
localPrivate files (invoices, reports)
publicPublicly accessible files (avatars, logos)
s3Production cloud storage

Storage Configuration

Filesystem Configuration

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

    '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',
            'throw' => false,
        ],

        '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,
        ],

        // Custom disk for project files
        'project_files' => [
            'driver' => 'local',
            'root' => storage_path('app/projects'),
            'visibility' => 'private',
        ],
    ],

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

Environment Variables

# Local development
FILESYSTEM_DISK=local

# Production with S3
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=client-portal-files
php artisan storage:link

File Uploads

Form Request Validation

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UploadProjectFileRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('uploadFile', $this->route('project'));
    }

    public function rules(): array
    {
        return [
            'file' => [
                'required',
                'file',
                'max:' . config('client_portal.files.max_size_kb', 10240), // 10MB
                'mimes:pdf,doc,docx,xls,xlsx,jpg,jpeg,png',
            ],
            'description' => ['nullable', 'string', 'max:255'],
        ];
    }

    public function messages(): array
    {
        return [
            'file.max' => 'The file size cannot exceed ' .
                (config('client_portal.files.max_size_kb', 10240) / 1024) . 'MB.',
        ];
    }
}

Controller File Upload

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\UploadProjectFileRequest;
use App\Models\Project;
use App\Models\ProjectFile;
use Illuminate\Support\Facades\Storage;

class ProjectFileController extends Controller
{
    public function store(UploadProjectFileRequest $request, Project $project)
    {
        $file = $request->file('file');

        // Generate unique filename
        $filename = $this->generateFilename($file);

        // Store the file
        $path = $file->storeAs(
            "projects/{$project->id}",
            $filename,
            'local'
        );

        // Create database record
        $projectFile = $project->files()->create([
            'filename' => $file->getClientOriginalName(),
            'path' => $path,
            'mime_type' => $file->getMimeType(),
            'size' => $file->getSize(),
            'description' => $request->validated('description'),
            'uploaded_by' => auth()->id(),
        ]);

        return redirect()
            ->back()
            ->with('success', 'File uploaded successfully.');
    }

    private function generateFilename($file): string
    {
        $extension = $file->getClientOriginalExtension();
        return uniqid() . '_' . time() . '.' . $extension;
    }
}

Service Class for Uploads

<?php

namespace App\Services;

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

class FileUploadService
{
    public function __construct(
        private string $disk = 'local'
    ) {}

    /**
     * Upload a file for a project.
     */
    public function uploadProjectFile(
        Project $project,
        UploadedFile $file,
        ?string $description = null
    ): ProjectFile {
        $path = $this->storeFile($file, "projects/{$project->id}");

        return $project->files()->create([
            'filename' => $file->getClientOriginalName(),
            'path' => $path,
            'mime_type' => $file->getMimeType(),
            'size' => $file->getSize(),
            'description' => $description,
            'uploaded_by' => auth()->id(),
        ]);
    }

    /**
     * Store a file with unique name.
     */
    public function storeFile(UploadedFile $file, string $directory): string
    {
        $filename = $this->generateUniqueFilename($file);

        return $file->storeAs($directory, $filename, $this->disk);
    }

    /**
     * Generate a unique filename.
     */
    private function generateUniqueFilename(UploadedFile $file): string
    {
        $extension = $file->getClientOriginalExtension();
        $basename = Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));

        return $basename . '_' . Str::random(8) . '.' . $extension;
    }

    /**
     * Delete a file.
     */
    public function deleteFile(ProjectFile $projectFile): bool
    {
        if (Storage::disk($this->disk)->exists($projectFile->path)) {
            Storage::disk($this->disk)->delete($projectFile->path);
        }

        return $projectFile->delete();
    }
}

Multiple File Upload

public function rules(): array
{
    return [
        'files' => ['required', 'array', 'max:10'],
        'files.*' => [
            'file',
            'max:10240',
            'mimes:pdf,doc,docx,xls,xlsx,jpg,jpeg,png',
        ],
    ];
}

public function store(Request $request, Project $project, FileUploadService $service)
{
    $uploadedFiles = collect();

    foreach ($request->file('files') as $file) {
        $uploadedFiles->push(
            $service->uploadProjectFile($project, $file)
        );
    }

    return redirect()
        ->back()
        ->with('success', $uploadedFiles->count() . ' files uploaded successfully.');
}

File Management

Basic Storage Operations

use Illuminate\Support\Facades\Storage;

// Check if file exists
Storage::exists('projects/1/document.pdf');
Storage::disk('s3')->exists('invoices/invoice-123.pdf');

// Get file contents
$contents = Storage::get('projects/1/document.pdf');

// Get file URL (public disk only)
$url = Storage::url('avatars/user-1.jpg');

// Get temporary URL (S3)
$url = Storage::temporaryUrl('invoices/invoice-123.pdf', now()->addMinutes(5));

// Get file metadata
$size = Storage::size('document.pdf');
$lastModified = Storage::lastModified('document.pdf');
$mimeType = Storage::mimeType('document.pdf');

// Copy and move
Storage::copy('old/file.pdf', 'new/file.pdf');
Storage::move('old/file.pdf', 'new/file.pdf');

// Delete files
Storage::delete('file.pdf');
Storage::delete(['file1.pdf', 'file2.pdf']);

// Delete directory
Storage::deleteDirectory('projects/123');

Directory Operations

// List files in directory
$files = Storage::files('projects/1');
$allFiles = Storage::allFiles('projects'); // Including subdirectories

// List directories
$directories = Storage::directories('projects');
$allDirectories = Storage::allDirectories('projects');

// Create directory
Storage::makeDirectory('projects/new-project');

File Model Pattern

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;

class ProjectFile extends Model
{
    protected $fillable = [
        'project_id',
        'filename',
        'path',
        'mime_type',
        'size',
        'description',
        'uploaded_by',
    ];

    protected $casts = [
        'size' => 'integer',
    ];

    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }

    public function uploader(): BelongsTo
    {
        return $this->belongsTo(User::class, 'uploaded_by');
    }

    /**
     * Get human-readable file size.
     */
    public function getFormattedSizeAttribute(): string
    {
        $bytes = $this->size;

        if ($bytes >= 1048576) {
            return number_format($bytes / 1048576, 2) . ' MB';
        }

        if ($bytes >= 1024) {
            return number_format($bytes / 1024, 2) . ' KB';
        }

        return $bytes . ' bytes';
    }

    /**
     * Get the file extension.
     */
    public function getExtensionAttribute(): string
    {
        return pathinfo($this->filename, PATHINFO_EXTENSION);
    }

    /**
     * Check if file is an image.
     */
    public function isImage(): bool
    {
        return str_starts_with($this->mime_type, 'image/');
    }

    /**
     * Check if file is a PDF.
     */
    public function isPdf(): bool
    {
        return $this->mime_type === 'application/pdf';
    }

    /**
     * Get file contents.
     */
    public function getContents(): ?string
    {
        if (Storage::exists($this->path)) {
            return Storage::get($this->path);
        }

        return null;
    }

    /**
     * Delete file from storage when model is deleted.
     */
    protected static function booted(): void
    {
        static::deleting(function (ProjectFile $file) {
            Storage::delete($file->path);
        });
    }
}

Secure Downloads

Authenticated File Downloads

<?php

namespace App\Http\Controllers;

use App\Models\ProjectFile;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;

class FileDownloadController extends Controller
{
    public function download(ProjectFile $file): StreamedResponse
    {
        // Authorization check
        Gate::authorize('download', $file);

        // Check file exists
        if (!Storage::exists($file->path)) {
            abort(404, 'File not found.');
        }

        return Storage::download(
            $file->path,
            $file->filename,
            ['Content-Type' => $file->mime_type]
        );
    }

    public function preview(ProjectFile $file): StreamedResponse
    {
        Gate::authorize('view', $file);

        if (!Storage::exists($file->path)) {
            abort(404, 'File not found.');
        }

        // Inline display (for PDFs, images)
        return Storage::response(
            $file->path,
            $file->filename,
            [
                'Content-Type' => $file->mime_type,
                'Content-Disposition' => 'inline; filename="' . $file->filename . '"',
            ]
        );
    }
}

Signed URLs for Temporary Access

<?php

namespace App\Http\Controllers;

use App\Models\ProjectFile;
use Illuminate\Support\Facades\URL;

class FileController extends Controller
{
    /**
     * Generate a signed download URL.
     */
    public function getDownloadLink(ProjectFile $file): array
    {
        $this->authorize('download', $file);

        $url = URL::signedRoute(
            'files.download.signed',
            ['file' => $file->id],
            now()->addMinutes(30)
        );

        return ['url' => $url, 'expires_at' => now()->addMinutes(30)];
    }
}

// Route definition
Route::get('/files/{file}/download', [FileDownloadController::class, 'downloadSigned'])
    ->name('files.download.signed')
    ->middleware('signed');

S3 Temporary URLs

public function getTemporaryUrl(ProjectFile $file): string
{
    $this->authorize('download', $file);

    return Storage::disk('s3')->temporaryUrl(
        $file->path,
        now()->addMinutes(15),
        [
            'ResponseContentDisposition' => 'attachment; filename="' . $file->filename . '"',
        ]
    );
}

Image Handling

Image Upload with Intervention

composer require intervention/image
<?php

namespace App\Services;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;

class ImageService
{
    /**
     * Upload and process an avatar image.
     */
    public function uploadAvatar(UploadedFile $file, int $userId): string
    {
        // Create image instance
        $image = Image::read($file);

        // Resize to square, maintaining aspect ratio
        $image->cover(200, 200);

        // Generate filename
        $filename = "avatars/{$userId}_" . time() . '.jpg';

        // Save to storage
        Storage::disk('public')->put(
            $filename,
            $image->toJpeg(quality: 85)
        );

        return $filename;
    }

    /**
     * Upload image with multiple sizes.
     */
    public function uploadWithThumbnails(UploadedFile $file, string $directory): array
    {
        $image = Image::read($file);
        $basename = pathinfo($file->hashName(), PATHINFO_FILENAME);
        $paths = [];

        // Original (max 1920px wide)
        $original = clone $image;
        $original->scaleDown(width: 1920);
        $paths['original'] = "{$directory}/{$basename}_original.jpg";
        Storage::disk('public')->put($paths['original'], $original->toJpeg(85));

        // Medium (800px)
        $medium = clone $image;
        $medium->scaleDown(width: 800);
        $paths['medium'] = "{$directory}/{$basename}_medium.jpg";
        Storage::disk('public')->put($paths['medium'], $medium->toJpeg(85));

        // Thumbnail (200px)
        $thumb = clone $image;
        $thumb->cover(200, 200);
        $paths['thumbnail'] = "{$directory}/{$basename}_thumb.jpg";
        Storage::disk('public')->put($paths['thumbnail'], $thumb->toJpeg(85));

        return $paths;
    }
}

Image Validation

public function rules(): array
{
    return [
        'avatar' => [
            'required',
            'image',
            'mimes:jpeg,jpg,png,webp',
            'max:2048', // 2MB
            'dimensions:min_width=100,min_height=100,max_width=4000,max_height=4000',
        ],
    ];
}

Image Display in Blade

{{-- Using Storage URL --}}
<img src="{{ Storage::url($user->avatar_path) }}" alt="{{ $user->name }}">

{{-- With fallback --}}
<img src="{{ $user->avatar_path
    ? Storage::url($user->avatar_path)
    : asset('images/default-avatar.png') }}"
    alt="{{ $user->name }}">

{{-- In model with accessor --}}
{{-- User.php --}}
public function getAvatarUrlAttribute(): string
{
    if ($this->avatar_path && Storage::disk('public')->exists($this->avatar_path)) {
        return Storage::url($this->avatar_path);
    }

    return asset('images/default-avatar.png');
}

{{-- In Blade --}}
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}">

Testing

Fake Storage

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

public function test_file_upload_stores_file(): void
{
    Storage::fake('local');

    $file = UploadedFile::fake()->create('document.pdf', 1024);
    $project = Project::factory()->create();

    $this->actingAs($admin)
        ->post(route('admin.projects.files.store', $project), [
            'file' => $file,
        ]);

    Storage::disk('local')->assertExists("projects/{$project->id}/" . $file->hashName());
}

public function test_file_deletion_removes_from_storage(): void
{
    Storage::fake('local');

    // Create a file
    Storage::disk('local')->put('projects/1/test.pdf', 'contents');

    $projectFile = ProjectFile::factory()->create([
        'path' => 'projects/1/test.pdf',
    ]);

    Storage::disk('local')->assertExists('projects/1/test.pdf');

    $this->actingAs($admin)
        ->delete(route('admin.projects.files.destroy', $projectFile));

    Storage::disk('local')->assertMissing('projects/1/test.pdf');
}

Testing Image Uploads

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

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

    $this->actingAs($user)
        ->post(route('profile.avatar'), ['avatar' => $file]);

    $user->refresh();

    Storage::disk('public')->assertExists($user->avatar_path);
}

Testing File Downloads

public function test_authorized_user_can_download_file(): void
{
    Storage::fake('local');
    Storage::disk('local')->put('projects/1/document.pdf', 'PDF content');

    $projectFile = ProjectFile::factory()->create([
        'path' => 'projects/1/document.pdf',
        'filename' => 'document.pdf',
    ]);

    $response = $this->actingAs($authorizedUser)
        ->get(route('files.download', $projectFile));

    $response->assertOk();
    $response->assertDownload('document.pdf');
}

public function test_unauthorized_user_cannot_download_file(): void
{
    $projectFile = ProjectFile::factory()->create();

    $response = $this->actingAs($unauthorizedUser)
        ->get(route('files.download', $projectFile));

    $response->assertForbidden();
}

Best Practices

Do

  • Use Storage facade, not raw file functions
  • Validate file types and sizes
  • Generate unique filenames
  • Store paths in database, not full URLs
  • Use private storage for sensitive files
  • Delete files when models are deleted
  • Use queued jobs for large file processing

Don't

  • Store files in the web root
  • Trust user-provided filenames
  • Skip file type validation
  • Store absolute paths in database
  • Forget to create storage symlink
  • Process large files synchronously

Security Considerations

// Always validate file types
$file->getMimeType(); // Get actual MIME type
$file->getClientMimeType(); // User-provided (can be spoofed)

// Sanitize filenames
$safeName = Str::slug(pathinfo($filename, PATHINFO_FILENAME));

// Store outside web root
Storage::disk('local')->put('private/document.pdf', $contents);

// Authenticate all downloads
Route::get('/files/{file}/download', [FileController::class, 'download'])
    ->middleware('auth');