Saltar al contenido
Ende Sun
Volver al blog

Backend y arquitectura

Bugs pequeños, impacto grande: tres casos reales en Symfony y PrestaShop

6 min de lectura

Bugs pequeños, impacto grande: tres casos reales en Symfony y PrestaShop

La mayoría de los bugs que he corregido en el trabajo no eran difíciles por la cantidad de código. Casi siempre la solución era una o dos líneas. Eran difíciles porque el código parecía correcto, el framework hacía exactamente lo que se le pedía y el síntoma aparecía en otro sitio: en la pasarela de pago, en una base de datos que conservaba datos antiguos o en un formulario que guardaba «con éxito» sin cambiar nada.

Estos tres casos vienen de mi trabajo en la plataforma de gestión de gimnasios de MasMusculo (Symfony) y en su tienda PrestaShop, en Osunasport. Los nombres y el código están simplificados, pero las causas son las reales.

Caso 1: filter() de Doctrine devuelve una copia

El síntoma

El personal del gimnasio crea rutinas de entrenamiento desde un back office hecho con EasyAdmin. Una rutina tiene varios días de entrenamiento y cada día una lista de ejercicios (series, repeticiones, descanso). Al editar los ejercicios de un día y guardar, la rutina quedaba inconsistente: filas que debían sustituirse seguían ahí junto a las nuevas.

Cómo funcionaba el guardado

Cada día de entrenamiento se pinta como un campo de colección propio en EasyAdmin, y al enviar el formulario se llama al setter de la entidad una vez por día. La lógica era sencilla: vaciar las filas de ese día y añadir las que llegan del formulario. Los días sin cambios se dejaban tal cual.

La versión problemática era más o menos así:

public function setRowsForDay(int $day, iterable $newRows): void
{
    // filter() devuelve una ArrayCollection NUEVA
    $rowsOfDay = $this->rows->filter(
        fn (RoutineRow $row) => $row->getDay() === $day
    );
    $rowsOfDay->clear(); // vacía la copia, no $this->rows

    foreach ($newRows as $row) {
        $row->setDay($day);
        $this->addRow($row);
    }
}

La causa

Collection::filter() no filtra sobre la propia colección: construye una colección nueva con los elementos que cumplen la condición. Vaciar esa colección nueva no afecta a $this->rows, que es la que Doctrine está siguiendo. Así que las filas antiguas nunca se eliminaban y las nuevas se añadían encima.

La solución

Eliminar los elementos de la colección que Doctrine gestiona de verdad:

foreach ($this->rows->toArray() as $row) {
    if ($row->getDay() === $day) {
        $this->removeRow($row); // removeElement() + orphanRemoval en la relación
    }
}

Recorrer toArray() evita modificar la colección mientras se itera sobre ella, y orphanRemoval en la relación hace que Doctrine borre las filas desvinculadas al hacer flush.

Lección: con colecciones de Doctrine, pregúntate siempre «¿estoy modificando la colección que se sigue o una copia?». filter(), map() y slice() devuelven algo nuevo.

Caso 2: un descuento de 0 € que Stripe no acepta

El síntoma

Algunos clientes que se suscribían con una promoción recibían un error procedente de Stripe y no podían completar la suscripción.

La causa

Las promociones se guardaban en cookies una vez validado el código, y el precio del producto se calculaba según el tipo de promoción. Algunas promociones fijaban un precio final en lugar de un porcentaje. Cuando ese precio fijo era igual o superior al precio normal del producto, el cálculo seguía generando un «descuento», pero de importe cero (o que habría sido negativo). Ese valor llegaba a Stripe, que no permite un descuento de 0 €.

Es decir, la plataforma intentaba aplicar una promoción que no le daba ningún beneficio al cliente, y el único efecto visible era un pago fallido.

La solución

La decisión tiene que tomarse en el cálculo del precio, antes de que nada llegue a la pasarela: si el precio final no es menor que el precio base, no hay descuento que aplicar.

public function calculatePrice(int $baseCents, ?Promotion $promo): int
{
    if ($promo === null) {
        return $baseCents;
    }

    $finalCents = $promo->apply($baseCents);

    // Sin descuento real: se comporta como si no hubiera promoción
    return $finalCents < $baseCents ? $finalCents : $baseCents;
}

Y al preparar el pago, el descuento solo se adjunta si es mayor que cero.

Lección: los valores «que no hacen nada» siguen siendo valores. Un descuento de cero, un array vacío o una fecha nula pueden ser perfectamente válidos en tu dominio y aun así ser rechazados por una API externa. Normalízalos en la frontera, no en el gestor de errores.

Caso 3: la lista vacía que nunca llega al servidor

El síntoma

Un módulo propio de PrestaShop permite excluir proveedores, productos o categorías de una funcionalidad de la tienda. Añadir exclusiones funcionaba. Quitar todas las exclusiones de un tipo, no: después de guardar, las antiguas seguían ahí.

(De paso: la página de configuración del módulo ni siquiera abría en mi entorno local hasta que subí el memory_limit de PHP a 512M. Conviene comprobarlo antes de dar por hecho que el fallo está en tu código.)

La causa

En el postProcess() del módulo, las exclusiones solo se guardaban si el valor enviado existía:

if (Tools::getValue('excluded_products')) {
    $this->saveExclusions('product', Tools::getValue('excluded_products'));
}

El navegador no envía un select múltiple sin nada seleccionado. Así que «el usuario ha vaciado la lista» y «este campo no venía en la petición» son exactamente lo mismo para el servidor, y la rama que habría borrado las exclusiones antiguas simplemente no se ejecutaba.

La solución

Procesar siempre el campo cuando se envía el formulario y tratar la ausencia de valor como una lista vacía:

if (Tools::isSubmit('submitExclusions')) {
    $ids = Tools::getValue('excluded_products', []);
    $ids = is_array($ids) ? array_map('intval', $ids) : [];

    $this->saveExclusions('product', $ids); // [] significa «quitar todas»
}

Lección: la ausencia de datos también es un dato. Cuando un formulario puede enviar legítimamente «nada», decide de forma explícita qué significa ese «nada» en lugar de dejar que lo decida una comprobación de veracidad.

Qué tienen en común

Ninguno de estos bugs lanzaba una excepción en nuestro propio código. Cada uno era un desajuste entre lo que el código parecía decir y lo que pasaba en realidad: una copia en lugar de una referencia, un cero que solo le importa a la API de otro y un valor vacío que no se distingue de uno ausente.

La costumbre que me llevé de ellos es mirar primero en las fronteras: entre mi código y el ORM, entre mi código y la pasarela de pago, entre el navegador y el servidor. Ahí es donde una línea que parece correcta suele dejar de serlo.