Skip to content

Patrón de Storage Local — Convención de Rutas

Tipo: Decisión Arquitectónica
Estado: En Uso (desde abril 2026)
Alcance: Todos los módulos que gestionen archivos/imágenes

Problema

El almacenamiento de archivos necesita una estructura predecible que:

  1. Aísle archivos por tenant (nro_sistema)
  2. Sea extensible a múltiples recursos (productos, crm, etc.)
  3. No dependa del schema de sucursal en el path físico — el UUID garantiza unicidad

Convención de Ruta

storage/images/{nro_sistema}/{recurso}/{id_entidad}/{uuid}.{ext}
SegmentoDescripciónEjemplo
storage/images/Raíz fija del storage de imágenes
{nro_sistema}Identificador del tenantemp001
{recurso}Nombre del módulo/entidadproductos, crm
{id_entidad}ID de la entidad dueña del archivo42
{uuid}.webpNombre único generado — garantiza unicidad sin schemaa3f2...webp

Ejemplo concreto:

storage/images/emp001/productos/42/a3f2c1d8-...webp
storage/images/emp001/crm/15/b9e4a2f1-...webp   ← futuro

Principio clave: LocalImageStorage es genérico

LocalImageStorage no sabe de productos. No tiene el recurso hardcodeado. Solo conoce la raíz storage/images/. El sub-path del recurso es responsabilidad del caller.

LocalImageStorage                StorageContext
─────────────────        vs.     ─────────────────
Sabe: storage/images/            Provee: {nroSistema}
No sabe: productos/              Provee: {resource}  ← "productos", "crm", etc.
                                 Provee: {entityId}

StorageContext

El contexto que se pasa al storage encapsula los tres componentes variables del path:

php
final class StorageContext
{
    public function __construct(
        public readonly string $nroSistema,   // tenant
        public readonly string $resource,     // 'productos', 'crm', etc.
        public readonly int    $entityId,     // id de la entidad
    ) {}
}

El path resultante que construye LocalImageStorage:

php
sprintf(
    'storage/images/%s/%s/%d/%s.webp',
    $ctx->nroSistema,
    $ctx->resource,
    $ctx->entityId,
    $uuid
)

Por qué no incluir el schema de sucursal

Los archivos físicos no se duplican por sucursal. Si suc0001 y suc0002 ambas tienen el producto #42, cada imagen subida genera su propio UUID. El schema en el path no aportaba aislamiento real — solo añadía un segmento redundante.

❌  storage/images/productos/emp001/suc0001/42/uuid.webp   ← schema en el medio
✅  storage/images/emp001/productos/42/uuid.webp           ← tenant al tope, uuid diferencia

El schema de sucursal sigue siendo relevante a nivel de tabla (producto_imagen existe en cada schema). El aislamiento de datos ocurre en la base de datos, no en el filesystem.

Registrar un nuevo recurso con imágenes

Al agregar soporte de imágenes a un nuevo módulo (ejemplo: CRM):

  1. StorageContext no cambia — solo pasar resource: 'crm'
  2. LocalImageStorage no cambia — es genérico
  3. El controller del módulo construye el contexto con el resource correcto:
php
$ctx = new StorageContext(
    nroSistema: (string) ($payload['sistema'] ?? ''),
    resource:   'crm',   // ← único cambio
    entityId:   $idContacto,
);
  1. Crear la tabla {entidad}_imagen equivalente a producto_imagen
  2. El service del módulo usa ProductoImagenService como referencia

Módulos con imágenes

MóduloResourceEstado
Ventas — Productosproductos✅ Implementado
CRMcrmPlanificado

Referencia de implementación

  • Modules/Ventas/Infrastructure/Storage/LocalImageStorage.php
  • Modules/Ventas/Infrastructure/Storage/StorageContext.php
  • Modules/Ventas/Infrastructure/Storage/ImageStorageStrategyInterface.php