Gestire i file su Amazon S3 in Laravel con Filament

Amazon S3 (Simple Storage Service) è uno dei servizi di archiviazione oggetti più diffusi e affidabili disponibili oggi. Integrato in un'applicazione Laravel con pannello amministrativo Filament, permette di gestire upload, download e cancellazione di file in modo scalabile e sicuro, senza dover dipendere dallo storage locale del server. In questo articolo vedremo come configurare S3 come driver di filesystem in Laravel, come collegarlo ai componenti di Filament e come costruire un flusso completo di gestione dei file dall'interfaccia amministrativa.

Prerequisiti

Prima di iniziare è necessario disporre di un'applicazione Laravel 10 o superiore con Filament v3 installato e configurato. Occorre inoltre un account AWS attivo con accesso alla console, dove andremo a creare un bucket S3 e le relative credenziali IAM. Sul lato PHP è richiesto il pacchetto league/flysystem-aws-s3-v3, che funge da adattatore tra il filesystem astratto di Laravel e le API di S3.

Installiamo il pacchetto necessario tramite Composer:

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

Creazione del bucket S3 su AWS

Accedendo alla console AWS, navighiamo nel servizio S3 e creiamo un nuovo bucket. Il nome del bucket deve essere globalmente univoco. Durante la creazione è importante disattivare il blocco degli accessi pubblici solo se si desidera rendere pubblicamente accessibili i file caricati; in caso contrario, è preferibile mantenere il blocco attivo e gestire gli accessi tramite URL pre-firmati.

Una volta creato il bucket, è necessario configurare una policy CORS per consentire le richieste provenienti dal dominio dell'applicazione. Dalla scheda "Autorizzazioni" del bucket, aggiungiamo la seguente configurazione CORS:

[
    {
        "AllowedHeaders": ["*"],
        "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
        "AllowedOrigins": ["https://example.com"],
        "ExposeHeaders": ["ETag"]
    }
]

Passiamo poi al servizio IAM per creare un utente dedicato all'applicazione. Assegniamo all'utente una policy inline con i permessi minimi necessari:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::nome-del-bucket",
                "arn:aws:s3:::nome-del-bucket/*"
            ]
        }
    ]
}

Dopo aver creato l'utente, generiamo le chiavi di accesso (Access Key ID e Secret Access Key) che useremo nella configurazione di Laravel.

Configurazione del filesystem in Laravel

Aggiungiamo le credenziali S3 al file .env del progetto:

AWS_ACCESS_KEY_ID=la-tua-access-key
AWS_SECRET_ACCESS_KEY=la-tua-secret-key
AWS_DEFAULT_REGION=eu-west-1
AWS_BUCKET=nome-del-bucket
AWS_URL=https://nome-del-bucket.s3.eu-west-1.amazonaws.com
AWS_ENDPOINT=
AWS_USE_PATH_STYLE_ENDPOINT=false

Laravel legge queste variabili nel file config/filesystems.php. Verifichiamo che il disco S3 sia definito correttamente:

<?php

return [

    // Disco predefinito per l'applicazione
    '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,
        ],

        // Configurazione del disco 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,
            // Visibilità predefinita per i file caricati
            'visibility' => 'private',
        ],

    ],

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

];

Se vogliamo impostare S3 come disco di default per tutta l'applicazione, è sufficiente cambiare la variabile FILESYSTEM_DISK nel file .env:

FILESYSTEM_DISK=s3

Modello e migrazione

Per illustrare un caso d'uso concreto, creiamo una risorsa per la gestione di documenti aziendali. Ogni documento avrà un nome, una descrizione e il percorso del file su S3. Generiamo il modello e la migrazione:

php artisan make:model Document -m

Definiamo la migrazione:

<?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('documents', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('description')->nullable();
            // Percorso del file all'interno del bucket S3
            $table->string('file_path');
            // Dimensione del file in byte
            $table->unsignedBigInteger('file_size')->nullable();
            // Tipo MIME del file
            $table->string('mime_type')->nullable();
            $table->string('disk')->default('s3');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('documents');
    }
};

Eseguiamo la migrazione:

php artisan migrate

Definiamo il modello Eloquent:

<?php

namespace App\Models;

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

class Document extends Model
{
    protected $fillable = [
        'name',
        'description',
        'file_path',
        'file_size',
        'mime_type',
        'disk',
    ];

    // Restituisce l'URL temporaneo del file su S3 (valido 60 minuti)
    public function getTemporaryUrlAttribute(): string
    {
        return Storage::disk($this->disk)
            ->temporaryUrl($this->file_path, now()->addMinutes(60));
    }

    // Restituisce la dimensione del file in formato leggibile
    public function getHumanFileSizeAttribute(): string
    {
        $bytes = $this->file_size ?? 0;

        if ($bytes < 1024) {
            return $bytes . ' B';
        } elseif ($bytes < 1048576) {
            return round($bytes / 1024, 2) . ' KB';
        } else {
            return round($bytes / 1048576, 2) . ' MB';
        }
    }
}

Creazione della risorsa Filament

Generiamo la risorsa Filament per i documenti:

php artisan make:filament-resource Document --generate

Questo comando crea automaticamente i file DocumentResource.php, ListDocuments.php, CreateDocument.php e EditDocument.php all'interno di app/Filament/Resources/. Modifichiamo DocumentResource.php per definire il form e la tabella:

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\DocumentResource\Pages;
use App\Models\Document;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Storage;

class DocumentResource extends Resource
{
    protected static ?string $model = Document::class;

    protected static ?string $navigationIcon = 'heroicon-o-document';

    protected static ?string $navigationLabel = 'Documenti';

    protected static ?string $modelLabel = 'Documento';

    protected static ?string $pluralModelLabel = 'Documenti';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\TextInput::make('name')
                    ->label('Nome')
                    ->required()
                    ->maxLength(255),

                Forms\Components\Textarea::make('description')
                    ->label('Descrizione')
                    ->rows(3)
                    ->columnSpanFull(),

                // Componente di upload collegato al disco S3
                Forms\Components\FileUpload::make('file_path')
                    ->label('File')
                    ->required()
                    ->disk('s3')
                    ->directory('documents')
                    ->visibility('private')
                    ->preserveFilenames()
                    ->maxSize(51200) // 50 MB in kilobyte
                    ->acceptedFileTypes([
                        'application/pdf',
                        'application/msword',
                        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                        'application/vnd.ms-excel',
                        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                        'image/jpeg',
                        'image/png',
                    ])
                    ->afterStateUpdated(function ($state, callable $set) {
                        // Aggiorniamo i metadati del file dopo il caricamento
                        if ($state) {
                            $path = is_array($state) ? array_key_first($state) : $state;
                            $set('disk', 's3');
                        }
                    })
                    ->columnSpanFull(),

                Forms\Components\Hidden::make('disk')
                    ->default('s3'),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('name')
                    ->label('Nome')
                    ->searchable()
                    ->sortable(),

                Tables\Columns\TextColumn::make('description')
                    ->label('Descrizione')
                    ->limit(50)
                    ->toggleable(),

                Tables\Columns\TextColumn::make('mime_type')
                    ->label('Tipo')
                    ->badge()
                    ->toggleable(),

                Tables\Columns\TextColumn::make('human_file_size')
                    ->label('Dimensione')
                    ->toggleable(),

                Tables\Columns\TextColumn::make('created_at')
                    ->label('Caricato il')
                    ->dateTime('d/m/Y H:i')
                    ->sortable()
                    ->toggleable(),
            ])
            ->filters([])
            ->actions([
                // Azione per scaricare il file tramite URL pre-firmato
                Tables\Actions\Action::make('download')
                    ->label('Scarica')
                    ->icon('heroicon-o-arrow-down-tray')
                    ->url(fn (Document $record): string => $record->temporary_url)
                    ->openUrlInNewTab(),

                Tables\Actions\EditAction::make(),
                Tables\Actions\DeleteAction::make()
                    ->after(function (Document $record) {
                        // Eliminiamo il file da S3 dopo la cancellazione del record
                        Storage::disk($record->disk)->delete($record->file_path);
                    }),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make()
                        ->after(function ($records) {
                            // Eliminiamo tutti i file selezionati da S3
                            foreach ($records as $record) {
                                Storage::disk($record->disk)->delete($record->file_path);
                            }
                        }),
                ]),
            ]);
    }

    public static function getRelations(): array
    {
        return [];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\ListDocuments::route('/'),
            'create' => Pages\CreateDocument::route('/create'),
            'edit' => Pages\EditDocument::route('/{record}/edit'),
        ];
    }
}

Gestione dei metadati dopo l'upload

Il componente FileUpload di Filament salva automaticamente il percorso del file nel campo del database, ma non popolerà automaticamente i campi file_size e mime_type. Per farlo, sovrascriviamo il metodo handleRecordCreation nella pagina di creazione e il metodo handleRecordUpdate nella pagina di modifica.

Modifichiamo app/Filament/Resources/DocumentResource/Pages/CreateDocument.php:

<?php

namespace App\Filament\Resources\DocumentResource\Pages;

use App\Filament\Resources\DocumentResource;
use App\Models\Document;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Storage;

class CreateDocument extends CreateRecord
{
    protected static string $resource = DocumentResource::class;

    // Intercettiamo la creazione per aggiungere i metadati del file
    protected function handleRecordCreation(array $data): Document
    {
        $filePath = $data['file_path'];
        $disk = $data['disk'] ?? 's3';

        // Recuperiamo dimensione e tipo MIME del file da S3
        if ($filePath && Storage::disk($disk)->exists($filePath)) {
            $data['file_size'] = Storage::disk($disk)->size($filePath);
            $data['mime_type'] = Storage::disk($disk)->mimeType($filePath);
        }

        return Document::create($data);
    }

    protected function getRedirectUrl(): string
    {
        return $this->getResource()::getUrl('index');
    }
}

Modifichiamo app/Filament/Resources/DocumentResource/Pages/EditDocument.php:

<?php

namespace App\Filament\Resources\DocumentResource\Pages;

use App\Filament\Resources\DocumentResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

class EditDocument extends EditRecord
{
    protected static string $resource = DocumentResource::class;

    protected function getHeaderActions(): array
    {
        return [
            Actions\DeleteAction::make(),
        ];
    }

    // Aggiorniamo i metadati se il file viene sostituito
    protected function handleRecordUpdate(Model $record, array $data): Model
    {
        $newFilePath = $data['file_path'];
        $disk = $data['disk'] ?? 's3';

        // Se il percorso del file è cambiato, aggiorniamo i metadati e puliamo il vecchio file
        if ($newFilePath !== $record->file_path) {
            if ($record->file_path && Storage::disk($disk)->exists($record->file_path)) {
                Storage::disk($disk)->delete($record->file_path);
            }

            if ($newFilePath && Storage::disk($disk)->exists($newFilePath)) {
                $data['file_size'] = Storage::disk($disk)->size($newFilePath);
                $data['mime_type'] = Storage::disk($disk)->mimeType($newFilePath);
            }
        }

        $record->update($data);

        return $record;
    }

    protected function getRedirectUrl(): string
    {
        return $this->getResource()::getUrl('index');
    }
}

URL pre-firmati e visibilità dei file

Quando i file sono archiviati con visibilità private su S3, non è possibile accedervi tramite URL pubblico diretto. La soluzione è l'utilizzo degli URL pre-firmati (presigned URL), che sono URL temporanei firmati crittograficamente dalle credenziali AWS e validi per un periodo di tempo definito.

Laravel mette a disposizione il metodo Storage::disk('s3')->temporaryUrl() per generarli. Abbiamo già utilizzato questo approccio nell'attributo getTemporaryUrlAttribute del modello. Possiamo anche generare URL pre-firmati direttamente nei controller o nei service:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Storage;

class FileService
{
    // Genera un URL pre-firmato con scadenza personalizzabile
    public function getPresignedUrl(string $filePath, int $expirationMinutes = 30): string
    {
        return Storage::disk('s3')->temporaryUrl(
            $filePath,
            now()->addMinutes($expirationMinutes)
        );
    }

    // Carica un file su S3 e restituisce il percorso
    public function upload(
        \Illuminate\Http\UploadedFile $file,
        string $directory = 'uploads',
        string $visibility = 'private'
    ): string {
        return Storage::disk('s3')->putFile(
            $directory,
            $file,
            $visibility
        );
    }

    // Elimina un file da S3
    public function delete(string $filePath): bool
    {
        if (Storage::disk('s3')->exists($filePath)) {
            return Storage::disk('s3')->delete($filePath);
        }

        return false;
    }

    // Verifica l'esistenza del file su S3
    public function exists(string $filePath): bool
    {
        return Storage::disk('s3')->exists($filePath);
    }

    // Restituisce i metadati del file
    public function getMetadata(string $filePath): array
    {
        $disk = Storage::disk('s3');

        return [
            'size' => $disk->size($filePath),
            'mime_type' => $disk->mimeType($filePath),
            'last_modified' => $disk->lastModified($filePath),
        ];
    }
}

Aggiungere un'azione di anteprima per le immagini

Per i file di tipo immagine è utile mostrare un'anteprima direttamente nel pannello Filament. Possiamo farlo aggiungendo un'azione condizionale nella tabella:

<?php

// Aggiungiamo questo metodo all'interno della classe DocumentResource

use Filament\Tables\Actions\Action;
use Filament\Support\Enums\MaxWidth;

// Azione di anteprima per le immagini
Action::make('preview')
    ->label('Anteprima')
    ->icon('heroicon-o-eye')
    ->visible(fn (Document $record): bool => str_starts_with($record->mime_type ?? '', 'image/'))
    ->modalContent(fn (Document $record) => view(
        'filament.modals.image-preview',
        ['url' => $record->temporary_url]
    ))
    ->modalWidth(MaxWidth::ExtraLarge)
    ->modalSubmitAction(false)
    ->modalCancelActionLabel('Chiudi'),

Creiamo la vista del modale in resources/views/filament/modals/image-preview.blade.php:

<!-- Vista per l'anteprima dell'immagine nel modale -->
<div class="flex items-center justify-center p-4">
    <img
        src="{{ $url }}"
        alt="Anteprima"
        class="max-w-full max-h-96 object-contain rounded-lg shadow"
    >
</div>

Widget per le statistiche dello storage

Filament permette di creare widget personalizzati per la dashboard. Creiamo un widget che mostra alcune statistiche sui file caricati:

php artisan make:filament-widget StorageStatsWidget --stats-overview

Modifichiamo il file generato in app/Filament/Widgets/StorageStatsWidget.php:

<?php

namespace App\Filament\Widgets;

use App\Models\Document;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class StorageStatsWidget extends BaseWidget
{
    protected function getStats(): array
    {
        // Calcoliamo le statistiche dei documenti
        $totalDocuments = Document::count();
        $totalSizeBytes = Document::sum('file_size');
        $recentUploads = Document::where('created_at', '>=', now()->subDays(7))->count();

        // Convertiamo la dimensione totale in un formato leggibile
        $totalSizeFormatted = $this->formatBytes($totalSizeBytes);

        return [
            Stat::make('Documenti totali', $totalDocuments)
                ->description('File archiviati su S3')
                ->color('primary')
                ->icon('heroicon-o-document'),

            Stat::make('Spazio utilizzato', $totalSizeFormatted)
                ->description('Dimensione totale dei file')
                ->color('warning')
                ->icon('heroicon-o-server'),

            Stat::make('Caricamenti recenti', $recentUploads)
                ->description('Ultimi 7 giorni')
                ->color('success')
                ->icon('heroicon-o-arrow-up-tray'),
        ];
    }

    // Formatta i byte in un formato leggibile dall'utente
    private function formatBytes(int $bytes): string
    {
        if ($bytes < 1024) {
            return $bytes . ' B';
        } elseif ($bytes < 1048576) {
            return round($bytes / 1024, 2) . ' KB';
        } elseif ($bytes < 1073741824) {
            return round($bytes / 1048576, 2) . ' MB';
        } else {
            return round($bytes / 1073741824, 2) . ' GB';
        }
    }
}

Registriamo il widget nel pannello Filament in app/Providers/Filament/AdminPanelProvider.php:

<?php

namespace App\Providers\Filament;

use App\Filament\Widgets\StorageStatsWidget;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServletEvents;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->default()
            ->id('admin')
            ->path('admin')
            ->colors(['primary' => Color::Amber])
            // Registriamo il widget delle statistiche
            ->widgets([
                StorageStatsWidget::class,
            ])
            ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
            ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
            ->middleware([
                EncryptCookies::class,
                AddQueuedCookiesToResponse::class,
                StartSession::class,
                AuthenticateSession::class,
                ShareErrorsFromSession::class,
                VerifyCsrfToken::class,
                SubstituteBindings::class,
                DisableBladeIconComponents::class,
                DispatchServletEvents::class,
            ])
            ->authMiddleware([
                Authenticate::class,
            ]);
    }
}

Pulizia automatica dei file orfani

Quando un record viene eliminato senza passare attraverso l'interfaccia Filament (ad esempio tramite comandi Artisan o eliminazioni in massa a livello di database), il file corrispondente su S3 potrebbe rimanere senza essere rimosso. La soluzione è agganciare la cancellazione del file agli eventi Eloquent nel modello:

<?php

namespace App\Models;

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

class Document extends Model
{
    protected $fillable = [
        'name',
        'description',
        'file_path',
        'file_size',
        'mime_type',
        'disk',
    ];

    // Registriamo il listener per la cancellazione automatica del file
    protected static function booted(): void
    {
        static::deleting(function (Document $document) {
            // Eliminiamo il file da S3 prima di cancellare il record
            if ($document->file_path) {
                Storage::disk($document->disk ?? 's3')->delete($document->file_path);
            }
        });
    }

    public function getTemporaryUrlAttribute(): string
    {
        return Storage::disk($this->disk)
            ->temporaryUrl($this->file_path, now()->addMinutes(60));
    }

    public function getHumanFileSizeAttribute(): string
    {
        $bytes = $this->file_size ?? 0;

        if ($bytes < 1024) {
            return $bytes . ' B';
        } elseif ($bytes < 1048576) {
            return round($bytes / 1024, 2) . ' KB';
        } else {
            return round($bytes / 1048576, 2) . ' MB';
        }
    }
}

In alternativa, è possibile creare un comando Artisan schedulato che confronta periodicamente i file presenti su S3 con i record del database e rimuove quelli orfani:

<?php

namespace App\Console\Commands;

use App\Models\Document;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;

class CleanOrphanedS3FilesCommand extends Command
{
    protected $signature = 'storage:clean-orphaned';

    protected $description = 'Rimuove da S3 i file non associati ad alcun record nel database';

    public function handle(): int
    {
        // Recuperiamo tutti i percorsi registrati nel database
        $databasePaths = Document::pluck('file_path')->toArray();

        // Recuperiamo tutti i file presenti nella directory principale su S3
        $s3Files = Storage::disk('s3')->allFiles('documents');

        $orphanedCount = 0;

        foreach ($s3Files as $s3File) {
            if (!in_array($s3File, $databasePaths)) {
                // Il file non ha un record corrispondente, lo eliminiamo
                Storage::disk('s3')->delete($s3File);
                $this->line("Eliminato: {$s3File}");
                $orphanedCount++;
            }
        }

        $this->info("Pulizia completata. File orfani eliminati: {$orphanedCount}");

        return self::SUCCESS;
    }
}

Registriamo il comando nello scheduler in app/Console/Kernel.php o nel file routes/console.php per le versioni più recenti di Laravel:

<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

// Pulizia dei file orfani ogni domenica a mezzanotte
Schedule::command('storage:clean-orphaned')->weekly()->sundays()->at('00:00');

Test con filesystem fittizio

Per i test automatici è fondamentale non effettuare chiamate reali ad AWS. Laravel mette a disposizione il metodo Storage::fake() che sostituisce il disco specificato con un filesystem in memoria, evitando qualsiasi interazione con S3 durante l'esecuzione dei test.

<?php

namespace Tests\Feature;

use App\Models\Document;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class DocumentManagementTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Sostituiamo il disco S3 con un filesystem fittizio in memoria
        Storage::fake('s3');
    }

    public function test_document_is_deleted_from_s3_when_record_is_deleted(): void
    {
        // Creiamo un file fittizio e lo carichiamo nel disco finto
        $file = UploadedFile::fake()->create('report.pdf', 100, 'application/pdf');
        $path = Storage::disk('s3')->putFile('documents', $file);

        // Creiamo il record nel database
        $document = Document::factory()->create([
            'file_path' => $path,
            'disk' => 's3',
        ]);

        // Verifichiamo che il file esista prima della cancellazione
        Storage::disk('s3')->assertExists($path);

        // Eliminiamo il record
        $document->delete();

        // Verifichiamo che il file sia stato rimosso da S3
        Storage::disk('s3')->assertMissing($path);
    }

    public function test_document_can_be_uploaded_through_filament(): void
    {
        $user = User::factory()->create();
        $file = UploadedFile::fake()->create('document.pdf', 200, 'application/pdf');

        // Simuliamo la creazione tramite il pannello Filament
        $this->actingAs($user)
            ->post('/admin/documents', [
                'name' => 'Documento di test',
                'description' => 'Descrizione di prova',
                'file_path' => $file,
                'disk' => 's3',
            ]);

        // Verifichiamo che il record esista nel database
        $this->assertDatabaseHas('documents', [
            'name' => 'Documento di test',
            'disk' => 's3',
        ]);
    }
}

Considerazioni sulla sicurezza

L'integrazione con S3 introduce alcune responsabilità in termini di sicurezza che è importante considerare. In primo luogo, le credenziali AWS non devono mai essere incluse nel codice sorgente né nei file di configurazione versionati; devono essere gestite esclusivamente tramite variabili d'ambiente e, in ambienti di produzione, tramite IAM Role associati all'istanza EC2 o al container.

In secondo luogo, gli URL pre-firmati hanno una scadenza e devono essere generati al momento della richiesta, non memorizzati nel database. Memorizzare un URL pre-firmato significherebbe avere un link valido solo per un periodo limitato e poi inutilizzabile, oltre a rappresentare un potenziale rischio se il database venisse compromesso.

Infine, è opportuno limitare i tipi di file accettati lato server, non solo lato client. Anche se Filament consente di filtrare i file tramite MIME type nel componente FileUpload, è buona pratica aggiungere una validazione esplicita nella request o nel controller:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreDocumentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string'],
            // Validazione lato server del tipo di file
            'file_path' => [
                'required',
                'file',
                'max:51200',
                'mimes:pdf,doc,docx,xls,xlsx,jpg,jpeg,png',
            ],
        ];
    }
}

Conclusioni

L'integrazione tra Amazon S3, Laravel e Filament offre una soluzione robusta e scalabile per la gestione dei file nelle applicazioni web. Grazie all'astrazione fornita dal filesystem di Laravel, è possibile passare da un disco locale a S3 modificando poche righe di configurazione, mentre Filament si occupa di rendere l'interfaccia amministrativa intuitiva e funzionale.

I punti chiave dell'implementazione che abbiamo affrontato comprendono la configurazione del disco S3 tramite le variabili d'ambiente, l'utilizzo del componente FileUpload di Filament con visibilità privata, la generazione di URL pre-firmati per l'accesso controllato ai file, la pulizia automatica dei file tramite gli eventi Eloquent e l'isolamento dei test tramite Storage::fake(). Questo approccio garantisce che l'archiviazione dei file sia separata dall'applicazione stessa, rendendo il sistema più resiliente, più facile da scalare orizzontalmente e più semplice da mantenere nel tempo.