Plan de Implementación: Imágenes de Variantes en Medusa v2.11.2+¶
Nota de vigencia (2026-08-11): este análisis es anterior a los fixes de junio 2026 (lookup scopeado de
product_item_viewy política de reintentos — ver incidente 2026-06). Partes pueden estar desactualizadas; vigencia a confirmar por el equipo.
Resumen¶
Este documento describe la implementación para sincronizar imágenes de variantes usando la nueva API nativa de Medusa v2.11.2+, reemplazando el enfoque actual basado en metadata.variant_images.
Requisitos Previos¶
- Backend de Medusa actualizado a v2.11.2 o superior
- Ejecutar migración en Medusa:
npx medusa db:migrate
1. Arquitectura Actual vs Nueva¶
Actual (metadata)¶
// En buildCreateVariantPayload()
'metadata' => [
'variant_images' => [
['url' => '...', 'alt' => '...', 'position' => 0]
]
]
Nueva (API nativa)¶
1. POST /admin/products/:id/images → Subir imagen al producto
2. POST /admin/products/:id/variants/:id/images/batch → Asociar a variante
3. POST /admin/products/:id/variants/:id → Asignar thumbnail
2. Nuevos Endpoints a Implementar en MedusaCatalogAdapter¶
// === Imágenes de Producto ===
/**
* Subir imagen al producto
* POST /admin/products/:id/images
*/
public function uploadProductImage(string $productId, string $imageUrl): ?array;
/**
* Listar imágenes del producto
* GET /admin/products/:id → extraer images[]
*/
public function listProductImages(string $productId): array;
/**
* Eliminar imagen del producto
* DELETE /admin/products/:id/images/:image_id
*/
public function deleteProductImage(string $productId, string $imageId): void;
// === Imágenes de Variante ===
/**
* Asociar/desasociar imágenes a una variante (batch)
* POST /admin/products/:id/variants/:variant_id/images/batch
* Body: { "add": ["img_id1"], "remove": ["img_id2"] }
*/
public function batchVariantImages(
string $productId,
string $variantId,
array $addImageIds = [],
array $removeImageIds = []
): array;
/**
* Actualizar thumbnail de variante
* POST /admin/products/:id/variants/:variant_id
* Body: { "thumbnail": "url" }
*/
public function updateVariantThumbnail(
string $productId,
string $variantId,
string $thumbnailUrl
): array;
3. Nuevo Servicio: MedusaVariantImageService¶
<?php
namespace App\Services\Medusa;
use App\Models\ProductItem;
use App\Models\ProductItemImage;
class MedusaVariantImageService
{
public function __construct(
private readonly MedusaCatalogAdapter $catalog,
private readonly MedusaClient $client
) {}
/**
* Sincroniza todas las imágenes de un ProductItem con Medusa
*/
public function syncVariantImages(ProductItem $item, string $productId, string $variantId): void
{
// 1. Obtener imágenes locales
$localImages = $item->images()->orderBy('id')->get();
if ($localImages->isEmpty()) {
return;
}
// 2. Obtener imágenes actuales en Medusa (del producto)
$remoteImages = $this->catalog->listProductImages($productId);
$remoteByUrl = collect($remoteImages)->keyBy('url');
// 3. Subir imágenes que no existen en Medusa
$imageIdsToAssociate = [];
foreach ($localImages as $localImg) {
$publicUrl = $this->getPublicUrl($localImg);
if ($remoteByUrl->has($publicUrl)) {
// Ya existe, usar su ID
$imageIdsToAssociate[] = $remoteByUrl[$publicUrl]['id'];
} else {
// Subir nueva imagen al producto
$uploaded = $this->catalog->uploadProductImage($productId, $publicUrl);
if ($uploaded && !empty($uploaded['id'])) {
$imageIdsToAssociate[] = $uploaded['id'];
}
}
}
// 4. Asociar imágenes a la variante (batch)
if (!empty($imageIdsToAssociate)) {
$this->catalog->batchVariantImages($productId, $variantId, $imageIdsToAssociate);
}
// 5. Asignar thumbnail (primera imagen con variante "thumbnail")
$firstImage = $localImages->first();
if ($firstImage) {
$thumbnailUrl = $this->getThumbnailUrl($firstImage);
$this->catalog->updateVariantThumbnail($productId, $variantId, $thumbnailUrl);
}
}
/**
* Obtiene URL pública de la imagen (Cloudflare o legacy)
*/
private function getPublicUrl(ProductItemImage $img): string
{
return $img->image_url; // Ya usa el accessor que prioriza Cloudflare
}
/**
* Obtiene URL del thumbnail (variante de Cloudflare)
* Reemplaza /public por /thumbnail en la URL
*/
private function getThumbnailUrl(ProductItemImage $img): string
{
if (!empty($img->cf_image_id)) {
return $img->getVariantUrl('thumbnail');
}
// Fallback: misma URL si no es Cloudflare
return $img->image_url;
}
}
4. Modificaciones en MedusaProductSyncService¶
4.1. Inyectar el nuevo servicio¶
public function __construct(
private readonly MedusaClient $client,
private ?CatalogPort $catalog = null,
private ?MedusaInventoryService $inventory = null,
private ?MedusaOptionService $options = null,
private ?MedusaCategoryService $categories = null,
private ?MedusaVariantImageService $images = null, // NUEVO
) {
// ... inicializaciones existentes ...
$this->images ??= new MedusaVariantImageService($this->catalog, $this->client);
}
4.2. Modificar syncVariantBasics()¶
public function syncVariantBasics(ProductItem $item): void
{
$ids = $this->ensureProductAndVariant($item);
$variantId = $ids['variant_id'];
$productId = $ids['product_id'];
// ... código existente de actualización ...
// === NUEVO: Sincronizar imágenes de variante ===
try {
$this->images->syncVariantImages($item, $productId, $variantId);
$this->mlog('info', 'Variant images:ok', $this->ctx($item, $productId, $variantId));
} catch (\Throwable $e) {
$this->mlog('error', 'Variant images:failed', $this->ctx($item, $productId, $variantId) + [
'err' => $e->getMessage(),
]);
}
$this->logSyncOk($item, 'medusa', 'variant_basic', null);
}
4.3. Modificar buildCreateVariantPayload()¶
Remover variant_images del metadata (ya no es necesario):
'metadata' => [
'short_description' => $item->short_description,
'sku_sap' => $item->sku_sap,
// ELIMINADO: 'variant_images' => $variantImages,
],
4.4. Agregar sincronización de imágenes post-creación¶
En createVariantForItem(), después de crear la variante:
// Sincronizar imágenes después de crear la variante
try {
$this->images->syncVariantImages($item, $productId, $vid);
} catch (\Throwable $e) {
$this->mlog('warning', 'Variant images:sync failed on create', $this->ctx($item, $productId, $vid) + [
'err' => $e->getMessage(),
]);
}
5. Implementación del Adapter (MedusaCatalogAdapter)¶
// === Imágenes de Producto ===
public function uploadProductImage(string $productId, string $imageUrl): ?array
{
try {
$res = $this->client->post("/admin/products/{$productId}/images", [
'url' => $imageUrl,
]);
return $res['image'] ?? $res ?? null;
} catch (ClientException $e) {
if ($e->getResponse()?->getStatusCode() === 409) {
// Imagen ya existe, no es error
return null;
}
throw $e;
}
}
public function listProductImages(string $productId): array
{
$product = $this->getProduct($productId);
return $product['images'] ?? [];
}
public function deleteProductImage(string $productId, string $imageId): void
{
try {
$this->client->delete("/admin/products/{$productId}/images/{$imageId}");
} catch (ClientException $e) {
if ($e->getResponse()?->getStatusCode() === 404) {
return; // Idempotente
}
throw $e;
}
}
// === Imágenes de Variante ===
public function batchVariantImages(
string $productId,
string $variantId,
array $addImageIds = [],
array $removeImageIds = []
): array {
$body = [];
if (!empty($addImageIds)) {
$body['add'] = array_values($addImageIds);
}
if (!empty($removeImageIds)) {
$body['remove'] = array_values($removeImageIds);
}
if (empty($body)) {
return ['added' => [], 'removed' => []];
}
$res = $this->client->post(
"/admin/products/{$productId}/variants/{$variantId}/images/batch",
$body
);
return $res;
}
public function updateVariantThumbnail(string $productId, string $variantId, string $thumbnailUrl): array
{
return $this->updateVariant($productId, $variantId, [
'thumbnail' => $thumbnailUrl,
]);
}
6. URLs de Cloudflare¶
Variantes configuradas¶
| Variante | Uso | URL Pattern |
|---|---|---|
public |
Imagen principal | /{hash}/{cf_image_id}/public |
thumbnail |
Miniatura de variante | /{hash}/{cf_image_id}/thumbnail |
Helper en ProductItemImage¶
Ya existe el método getVariantUrl():
public function getVariantUrl(string $variant): string
{
if (!empty($this->cf_image_id)) {
return app(CloudflareImagesService::class)->url($this->cf_image_id, $variant);
}
return $this->image_url;
}
7. Migración de Datos Existentes¶
Crear comando para sincronizar imágenes de variantes existentes:
php artisan medusa:sync-variant-images
--dry-run # Mostrar cambios sin aplicar
--limit=100 # Limitar cantidad
--product-item=123 # Sincronizar uno específico
8. Orden de Implementación¶
- Fase 1: Adapter (sin afectar flujo actual)
- Agregar métodos de imágenes a
MedusaCatalogAdapter - Crear
MedusaVariantImageService -
Tests unitarios
-
Fase 2: Integración (activar gradualmente)
- Agregar variable de entorno
MEDUSA_VARIANT_IMAGES_ENABLED=false - Modificar
syncVariantBasics()con condicional -
Probar con productos seleccionados
-
Fase 3: Migración
- Ejecutar comando de migración masiva
- Remover
variant_imagesdel metadata -
Activar por defecto
-
Fase 4: Limpieza
- Remover código legacy de metadata
- Remover condicional de feature flag
9. Feature Flag¶
# .env
MEDUSA_VARIANT_IMAGES_ENABLED=false # Cambiar a true cuando esté listo
// En MedusaProductSyncService
private function shouldUseNativeVariantImages(): bool
{
return (bool) config('medusa.variant_images_enabled', false);
}
10. Resultado Esperado¶
Antes (metadata)¶
{
"variant": {
"metadata": {
"variant_images": [{"url": "...", "alt": "..."}]
}
}
}
Después (API nativa)¶
{
"variant": {
"id": "var_123",
"thumbnail": "https://imagedelivery.net/.../thumbnail",
"images": [
{"id": "img_123", "url": "https://imagedelivery.net/.../public"}
]
}
}
Notas¶
- Las imágenes se suben al producto y se asocian a variantes
- Una imagen puede estar asociada a múltiples variantes
- Imágenes no asociadas a ninguna variante aparecen para todas
- El thumbnail es independiente del array de imágenes