main content
< Volver a blog sobre aplicaciones móviles

Soft delete y papelera en una app web a medida

Cómo implementar soft delete y papelera de reciclaje en una aplicación web a medida

Hay un email que todos los que llevamos años desarrollando hemos recibido alguna vez: "Hemos borrado sin querer la ficha del cliente más importante, ¿se puede recuperar?". Y si en su día programaste un DELETE físico contra la base de datos sin pensarlo dos veces, la respuesta honesta es un silencio incómodo seguido de "voy a mirar los backups". Esa escena, vivida más de una vez, es la mejor razón para entender bien cómo implementar soft delete y papelera de reciclaje en una aplicación web a medida antes de que el problema te muerda en producción.

En este artículo te cuento el patrón completo: qué es el borrado lógico, cómo evitar el bug clásico de olvidar el filtro, cómo montar una papelera con retención y purga, los problemas reales con índices y claves foráneas, y cómo encaja todo esto con el RGPD sin pegarte un tiro en el pie. Sin humo, con las cicatrices que deja hacerlo mal.

Borrado físico vs borrado lógico: por qué el DELETE real te muerde

El borrado físico (o hard delete) es lo que hace DELETE FROM clientes WHERE id = 42. La fila desaparece. Punto. No hay vuelta atrás salvo que tengas un backup razonablemente reciente y estés dispuesto a montar un proceso de restauración parcial, que casi nunca es trivial.

El borrado lógico (o soft delete) no elimina nada: marca la fila como borrada, normalmente con una columna deleted_at que pasa de NULL a la fecha y hora del borrado. La fila sigue ahí, pero tu aplicación se comporta como si no existiera.

¿Por qué el borrado físico te acaba mordiendo? Por tres motivos que se repiten en proyecto tras proyecto:

  • Datos perdidos que no deberían perderse. Un usuario borra una factura que en realidad solo quería archivar. Con hard delete, has perdido un documento contable. Eso, en España, puede ser un problema de los gordos.
  • Integridad referencial rota. Borras un proveedor y de repente tienes 300 pedidos apuntando a un proveedor_id que ya no existe. O bien la base de datos te lo impide (y el borrado falla con un error feo), o tienes un ON DELETE CASCADE que se lleva por delante medio sistema.
  • Soporte que no puede recuperar nada. El equipo de atención al cliente recibe la llamada, abre una incidencia, y la única respuesta posible es "lo siento, eso ya no está". Con soft delete, soporte entra en la papelera, pulsa "restaurar" y el problema se resuelve en treinta segundos.

No estoy diciendo que el soft delete sea siempre la respuesta (más adelante veremos cuándo NO usarlo), pero para la mayoría de entidades de negocio en una aplicación a medida, el borrado lógico es lo sensato.

El patrón deleted_at y el filtro por defecto

La implementación base es sencilla. Añades una columna anulable a la tabla:

ALTER TABLE clientes
  ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL;

Si deleted_at es NULL, el registro está vivo. Si tiene fecha, está en la papelera. Prefiero deleted_at (un timestamp) a un simple is_deleted booleano porque te da gratis la información de cuándo se borró, que es justo lo que necesitas para la retención y la purga. Un booleano te obliga a añadir luego otra columna con la fecha, así que mejor empieza bien.

El verdadero reto no es la columna. Es acordarte de filtrar siempre por ella. Aquí está el bug clásico que ha provocado más de un susto: alguien escribe un nuevo listado, hace un SELECT * FROM clientes directo, y la pantalla empieza a mostrar clientes borrados como si nada. O peor: un informe envía emails a usuarios que habían pedido la baja.

La defensa correcta es no confiar en la memoria de nadie. Los ORM modernos lo resuelven con scopes globales.

En Laravel/Eloquent lo tienes prácticamente regalado con el trait SoftDeletes:

use Illuminate\Database\Eloquent\SoftDeletes;

class Cliente extends Model
{
    use SoftDeletes;
    // Eloquent añade automáticamente WHERE deleted_at IS NULL
    // a todas las consultas del modelo.
}

A partir de ahí, Cliente::all() ignora los borrados. Para incluirlos usas withTrashed(), para ver solo la papelera onlyTrashed(), y para restaurar $cliente->restore(). El truco está en que el filtro es opt-out, no opt-in: por defecto estás seguro, y solo cuando explícitamente quieres ver lo borrado tienes que pedirlo.

En Doctrine (Symfony) el equivalente es un filtro SQL que se registra y se activa para que se añada la condición a todas las consultas de esa entidad. En Sequelize (Node) tienes la opción paranoid: true en el modelo, que hace exactamente lo mismo con una columna deletedAt. Sea cual sea tu stack, la idea es idéntica: el filtro vive en una sola capa central, no esparcido por cada query. Cada vez que confías en que el desarrollador "se acuerde" de poner WHERE deleted_at IS NULL, estás firmando un futuro incidente.

Un aviso desde la trinchera: ojo con las consultas en SQL crudo, los JOIN y las vistas materializadas. El scope global solo protege las consultas que pasan por el ORM. Esos SELECT a pelo que metiste para un informe rápido un viernes por la tarde se saltan el filtro alegremente. Revísalos.

La papelera de reciclaje: retención, restauración y purga

Soft delete y papelera no son lo mismo, aunque se confundan. El soft delete es el mecanismo técnico; la papelera de reciclaje es la experiencia de producto que construyes encima. Y para que sea útil necesita tres piezas.

Retención

Decide cuánto tiempo permanece un elemento en la papelera antes de desaparecer de verdad. El estándar de facto son 30 días, igual que muchos servicios conocidos, pero el número correcto depende de tu dominio. Para documentos legales quizá quieras 90; para borradores efímeros, 7 puede sobrar. Lo importante es que sea una decisión consciente y, idealmente, configurable.

Restauración

La operación inversa: poner deleted_at de nuevo a NULL. Suena trivial, pero tiene matices. ¿Qué pasa si restauras un cliente cuya empresa asociada también fue borrada? ¿Restauras la cadena entera o dejas un huérfano? Conviene avisar al usuario en estos casos en lugar de dejar el sistema en un estado raro. Y registra siempre quién restaura, igual que registras quién borra.

Purga definitiva programada

Pasados los días de retención, los elementos deben eliminarse de verdad. Esto sí es un hard delete, y se ejecuta de forma automática con un trabajo programado (un cron o una cola de jobs). En Laravel sería un comando de consola lanzado por el scheduler:

// app/Console/Commands/PurgarPapelera.php
public function handle(): int
{
    $limite = now()->subDays(30);

    Cliente::onlyTrashed()
        ->where('deleted_at', '<', $limite)
        ->forceDelete(); // borrado físico real

    return self::SUCCESS;
}

Mi recomendación: que la purga sea conservadora y observable. Procesa por lotes para no bloquear la base de datos, escribe en el log cuántas filas eliminó cada ejecución, y plantéate una "papelera de la papelera" (un volcado a almacenamiento frío o un export) para las entidades de mayor criticidad. El día que alguien purgue por error querrás tener una última red.

Los problemas reales que nadie te cuenta en el tutorial

Aquí es donde el soft delete deja de ser bonito y empiezan los detalles que separan una implementación de juguete de una de producción.

Índices únicos que chocan con filas borradas

Este es el clásico que pilla a todo el mundo. Tienes una restricción UNIQUE sobre el email del usuario. Un usuario se da de baja (soft delete), y meses después se quiere registrar otra vez con el mismo email. La inserción falla: la fila vieja, aunque borrada, sigue ocupando el valor único.

La solución elegante en PostgreSQL es un índice único parcial, que solo aplica a las filas vivas:

CREATE UNIQUE INDEX uniq_clientes_email_vivos
  ON clientes (email)
  WHERE deleted_at IS NULL;

Así, dos filas pueden compartir email siempre que como máximo una esté viva. En MySQL, que históricamente no soporta índices parciales, el truco habitual es una columna generada que vale NULL cuando el registro está borrado (y los NULL no colisionan en un índice único) o incluir deleted_at en la propia clave única. No es tan limpio, pero funciona.

Borrado en cascada lógico

Si borras una factura, ¿qué pasa con sus líneas? Con hard delete el ON DELETE CASCADE lo resolvía la base de datos. Con soft delete, la cascada es tu responsabilidad a nivel de aplicación: tienes que propagar el deleted_at a las entidades hijas, y luego propagar también el restore(). Esto se hace habitualmente con eventos o hooks del modelo. Es fácil olvidar una relación y dejar hijos vivos colgando de un padre borrado, así que mapéalo de forma explícita.

Rendimiento de los índices con el flag

Cada consulta lleva ahora un WHERE deleted_at IS NULL. En tablas grandes, si no lo indexas, pagas en rendimiento. Pero un índice tonto sobre deleted_at solo no suele bastar: lo que de verdad ayuda es meter esa condición en índices compuestos alineados con tus consultas reales, o usar índices parciales que indexen únicamente las filas vivas (que suelen ser la mayoría de las que consultas). Mide con EXPLAIN antes de inventar índices a ciegas.

Claves foráneas

Las claves foráneas siguen apuntando a filas que existen físicamente (porque el soft delete no las elimina), así que la integridad referencial de la base de datos no se rompe. Bien. El matiz es que tu lógica de negocio ya no puede dar por hecho que "si la FK resuelve, la entidad está viva". Una FK puede resolver perfectamente a un registro que está en la papelera. Tenlo presente al construir validaciones.

La tensión con el RGPD: la papelera no es eterna

Aquí viene la parte que en una agencia que trabaja en España no podemos pasar por alto. El RGPD reconoce el derecho de supresión (el famoso "derecho al olvido"). Y el soft delete, por su propia naturaleza, no borra los datos: los esconde. Si un usuario ejerce su derecho de supresión y tú lo único que haces es poner un deleted_at, técnicamente sigues conservando sus datos personales. Eso no cumple.

La forma de conciliar ambas cosas:

  • Distingue "el usuario borra" de "el usuario ejerce su derecho de supresión". No son la misma acción. El primero puede ir a la papelera; el segundo dispara un proceso distinto y más serio.
  • Anonimiza o purga de verdad. Cuando hay que suprimir datos personales, sustituye los campos identificativos por valores anónimos (nombre, email, NIF, dirección) o elimina físicamente el registro. A menudo no puedes borrar la fila entera porque hay datos contables que la ley te obliga a conservar varios años, así que la anonimización es el punto medio: mantienes la integridad de la factura pero ya no hay datos personales detrás.
  • Que la purga programada respete los plazos. Tu trabajo de purga es, de hecho, parte de tu cumplimiento. Documenta los plazos de retención y asegúrate de que el cron los aplica de verdad.

La regla mental que me funciona: la papelera es una comodidad operativa con fecha de caducidad; el derecho de supresión es una obligación legal que termina, sí o sí, en datos personales eliminados o anonimizados. No mezcles ambos conceptos en la misma columna y la misma lógica. Si tu sector maneja datos sensibles y no lo tienes claro, es justo el tipo de cosa que conviene revisar con alguien que se haya peleado antes con esto; en Tangram lo abordamos a menudo y puedes contarnos tu caso concreto para no improvisar sobre algo que tiene implicaciones legales.

Auditoría: quién borró y cuándo

Una papelera sin trazabilidad es media papelera. Cuando algo desaparece, la primera pregunta del cliente no es "¿se puede recuperar?", es "¿quién lo borró?". Por eso, además del deleted_at, conviene registrar el autor del borrado.

Hay dos niveles. El mínimo es una columna deleted_by que guarda el ID del usuario que ejecutó la acción. El nivel completo es integrar el borrado con tu audit log: una tabla o servicio que registra, para cada acción sensible, quién, qué, cuándo y desde dónde. El soft delete encaja de forma natural ahí, porque el evento de borrado (y el de restauración) son acciones auditables de pleno derecho. Si ya tienes un sistema de auditoría, haz que el borrado lógico emita sus eventos hacia él en lugar de inventar un mecanismo paralelo.

Este registro no es solo para echar culpas. Es lo que te permite reconstruir qué pasó cuando hay un incidente, y es además un apoyo de cara a demostrar diligencia en materia de protección de datos.

Recomendaciones de implementación y cuándo NO usar soft delete

Después de haberlo implementado unas cuantas veces, estas son las decisiones que repito:

  • Usa deleted_at (timestamp), no is_deleted (booleano). Te da la fecha gratis y te ahorra una columna extra para la retención.
  • Centraliza el filtro en el ORM con un scope global. Nunca confíes en que cada query recuerde el WHERE.
  • Índices únicos parciales para los campos únicos, desde el día uno. Reintroducirlos después, con datos ya borrados que colisionan, es mucho más doloroso.
  • Cascada y restauración explícitas. Mapea las relaciones padre-hijo a mano y testéalas.
  • Purga programada y observable, con logs y procesamiento por lotes.
  • Separa papelera de derecho de supresión, y resuelve este último con anonimización o borrado real.
  • Audita cada borrado y restauración.

¿Y cuándo NO conviene el soft delete? No es una bala de plata:

  • En tablas de relación pura (las pivot de muchos-a-muchos) suele sobrar; añade complejidad sin valor.
  • En logs, eventos o datos de altísimo volumen donde arrastrar filas borradas degrada el rendimiento y nunca vas a "restaurar" un evento. Ahí es mejor un particionado o una política de archivado.
  • Cuando el RGPD o un requisito de seguridad exigen borrado inmediato y verificable. Si el dato no puede seguir existiendo ni un minuto, el soft delete es justo lo contrario de lo que necesitas.
  • En datos efímeros o cachés, donde recuperar no tiene sentido.

El soft delete bien hecho es una de esas decisiones de arquitectura que pasa desapercibida cuando funciona y se nota muchísimo cuando falta. No es complicado de implementar, pero los detalles (índices únicos, cascada, purga, RGPD, auditoría) son donde se juega de verdad la partida. Si lo dejas a medias, acabarás escribiendo ese email incómodo sobre los backups. Y créeme: es mejor no tener que escribirlo nunca.

Contacta con nosotros
Fila 1