Apariencia
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
| Tipo | Origen | Disco Laravel | Carpeta | Formatos | Tamaño máx |
|---|---|---|---|---|---|
| Avatar de usuario | POST /api/v1/me/profile/avatar | public | avatars/{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/documents | local (privado) | documents/{candidate_profile_id}/ | pdf, jpg, png, webp, doc, docx | 10 MB |
| Logo de empresa (Fase 2) | futuro | public | companies/{company_id}/ | jpg, png, webp, svg | 4 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 symlinkpublic/storage → storage/app/public). - Se crea el symlink corriendo
php artisan storage:linkuna 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:que valida sesión Sanctum + Policy de ownership y hace stream del archivo con
GET /api/v1/me/profile/documents/{id}/downloadStorage::disk('local')->download(...).
Variables de entorno
env
FILESYSTEM_DISK=public # disco por defecto para llamadas sin parámetro explícitoNo 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_ides la ruta relativa dentro del disco (ej.avatars/42/abc123.webp). Se guarda en DB para borrar el archivo después.- Si
options.transformestá presente, la imagen se recodifica con intervention/image (GD) acover(width, height)y se guarda comowebpcon calidad 85. urlsólo se devuelve cuando el disco espublic. Paralocalel 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:
| Recurso | Por candidato | Total |
|---|---|---|
| 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:
- Diario:
restic backup /var/www/humae_backend/storage/appa un bucket S3 externo / disco cifrado offsite. - Retention: 14 días diarios + 12 semanas + 12 meses (
restic forget --keep-daily 14 --keep-weekly 12 --keep-monthly 12 --prune). - Prueba mensual: restaurar un snapshot a un directorio temporal y validar la integridad.
En infra ver: Infraestructura → Almacenamiento.
Borrado
- Al reemplazar un avatar:
AvatarControllerborra el archivo viejo usandoavatar_pathantes de sobrescribir. - Al eliminar un documento:
DocumentController::destroy()borra el archivo físico víaStorage::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 condeleted_at > 90 días.
Errores comunes
| Error | Causa | Fix |
|---|---|---|
The /storage/... URL devuelve 404 | No se corrió php artisan storage:link | Crear el symlink una vez |
Permission denied al subir | El usuario del proceso PHP-FPM no tiene write en storage/app | chown -R www-data:www-data storage && chmod -R 775 storage |
| Imagen se corrompe al subir | GD no soporta el formato original | Validar mimes: en FormRequest |
| 413 Request Entity Too Large | Nginx client_max_body_size por debajo del límite | client_max_body_size 12M; |
Siguiente
Correos con SMTP local: SMTP local →

