Skip to content

Storage local — archivos en disco del servidor

HUMAE guarda todas las imágenes y documentos subidos por los usuarios en el disco del mismo servidor que corre el backend. No se usa ningún SaaS de terceros (ni Cloudinary, ni S3 externo) — es un requisito arquitectónico del proyecto.

Regla inquebrantable

Nunca escribas a public_path() directamente. Siempre usa Storage::disk(...) via el helper LocalFileStorage para obtener trazabilidad, rutas predecibles y backups consistentes.

Tipos de archivos almacenados

TipoOrigenDisco LaravelCarpetaFormatosTamaño máx
Avatar de usuarioPOST /api/v1/me/profile/avatarpublicavatars/{user_id}/jpg, jpeg, png, webp (se convierten a webp 400×400)4 MB
Documentos del candidato (CV, cartas, ID)POST /api/v1/me/profile/documentslocal (privado)documents/{candidate_profile_id}/pdf, jpg, png, webp, doc, docx10 MB
Logo de empresa (Fase 2)futuropubliccompanies/{company_id}/jpg, png, webp, svg4 MB

Discos

Laravel soporta múltiples discos (config/filesystems.php). HUMAE usa dos:

public — archivos accesibles vía web

  • Raíz física: storage/app/public/
  • URL pública: {APP_URL}/storage/... (gracias al symlink public/storage → storage/app/public).
  • Se crea el symlink corriendo php artisan storage:link una vez, como parte del deploy.
  • Apto para avatares y logos. Nunca para documentos sensibles.

local — archivos privados

  • Raíz física: storage/app/private/
  • Sin URL pública directa.
  • Los documentos sensibles (CV, IDs) van aquí y se sirven a través del endpoint autenticado:
    GET /api/v1/me/profile/documents/{id}/download
    que valida sesión Sanctum + Policy de ownership y hace stream del archivo con Storage::disk('local')->download(...).

Variables de entorno

env
FILESYSTEM_DISK=public   # disco por defecto para llamadas sin parámetro explícito

No se necesita ninguna credencial — es todo filesystem local.

Implementación

LocalFileStorage helper

Wrapper sobre Storage que los controllers reciben por DI. Firma:

php
namespace App\Helpers;

class LocalFileStorage
{
    /**
     * @param  array{disk?: string, transform?: array{width?: int, height?: int}}  $options
     * @return array{url: string|null, public_id: string, mime_type: string|null, size: int|null}
     */
    public function upload(UploadedFile $file, string $folder, array $options = []): array;

    public function destroy(string $publicId, string $disk = 'public'): void;
}
  • public_id es la ruta relativa dentro del disco (ej. avatars/42/abc123.webp). Se guarda en DB para borrar el archivo después.
  • Si options.transform está presente, la imagen se recodifica con intervention/image (GD) a cover(width, height) y se guarda como webp con calidad 85.
  • url sólo se devuelve cuando el disco es public. Para local el controller construye la URL del endpoint autenticado.

Subida de avatar

php
// AvatarController::store()
$uploaded = $this->storage->upload($file, 'avatars/'.$user->id, [
    'disk' => 'public',
    'transform' => ['width' => 400, 'height' => 400],
]);

$user->forceFill([
    'avatar_url' => $uploaded['url'],         // https://api.humae.com.mx/storage/avatars/42/abc123.webp
    'avatar_path' => $uploaded['public_id'],  // avatars/42/abc123.webp
])->save();

Cuando el usuario reemplaza su avatar, se borra el archivo anterior usando avatar_path.

Subida de documento

php
// DocumentController::store()
$uploaded = $this->storage->upload($file, 'documents/'.$profile->id, [
    'disk' => 'local',
]);

$document = $profile->documents()->create([
    'file_provider' => 'local',
    'file_public_id' => $uploaded['public_id'],   // documents/7/zzz.pdf
    'file_url' => route('me.profile.documents.download', ['document' => $id]),
    // ...
]);

Descarga de documento privado

php
// DocumentController::download()
$this->ensureOwned($request, $document->candidate_profile_id);
return Storage::disk('local')->download($document->file_public_id, $document->title.'.pdf');

Protegido por auth:sanctum + throttle 60/min.

Validación previa al upload

En el controller:

php
$request->validate([
    'avatar' => ['required', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
]);

max:4096 es en KB (= 4 MB). Si falla, se devuelve 422 antes de tocar el disco.

Rate limits

  • Avatar upload: 10/min por usuario.
  • Document upload: 20/min.
  • Document download (endpoint privado): 60/min.
  • CV download (PDF generado): 30/min.

Dimensionamiento

Estimación para ~500 candidatos activos:

RecursoPor candidatoTotal
Avatar (WebP 400×400)~40 KB~20 MB
Documentos (5 × ~500 KB)~2.5 MB~1.25 GB
Total~2.5 MB~1.3 GB

Para 5 000 candidatos activos: ~13 GB. Provisiona ~50 GB iniciales en el volumen de storage/ con alertas al 70 %.

Backups

El directorio humae_backend/storage/app es crítico — si se pierde no hay manera de recuperarlo.

Estrategia recomendada:

  1. Diario: restic backup /var/www/humae_backend/storage/app a un bucket S3 externo / disco cifrado offsite.
  2. Retention: 14 días diarios + 12 semanas + 12 meses (restic forget --keep-daily 14 --keep-weekly 12 --keep-monthly 12 --prune).
  3. Prueba mensual: restaurar un snapshot a un directorio temporal y validar la integridad.

En infra ver: Infraestructura → Almacenamiento.

Borrado

  • Al reemplazar un avatar: AvatarController borra el archivo viejo usando avatar_path antes de sobrescribir.
  • Al eliminar un documento: DocumentController::destroy() borra el archivo físico vía Storage::disk('local')->delete($file_public_id) y soft-deletea el registro.
  • Al soft-delete de un candidato: los archivos no se borran inmediatamente. Un job CleanupDeletedAccountsJob (Fase 2) borra los archivos de cuentas con deleted_at > 90 días.

Errores comunes

ErrorCausaFix
The /storage/... URL devuelve 404No se corrió php artisan storage:linkCrear el symlink una vez
Permission denied al subirEl usuario del proceso PHP-FPM no tiene write en storage/appchown -R www-data:www-data storage && chmod -R 775 storage
Imagen se corrompe al subirGD no soporta el formato originalValidar mimes: en FormRequest
413 Request Entity Too LargeNginx client_max_body_size por debajo del límiteclient_max_body_size 12M;

Siguiente

Correos con SMTP local: SMTP local →

Manual de usuario HUMAE · Uso interno