Etiquetar es de esas funcionalidades que parecen triviales hasta que hay que ponerlas en el cuarto modelo. Entonces te encuentras con tablas casi idénticas, el mismo código repetido en varios sitios y una petición de producto que no puedes resolver sin migrar media base de datos. Lo que sigue es cómo montarlo una sola vez para cualquier modelo, con las decisiones que evitan que la limpieza y los permisos se te compliquen después. Los ejemplos van en Laravel, una de nuestras especialidades, y comparamos abiertamente con spatie/laravel-tags, que es lo que usa casi todo el mundo, pero el modelo de datos es portable a cualquier stack.
Una tabla de etiquetas por modelo es el camino que no escala
Empieza siempre igual. Necesitas etiquetar documentos, así que creas document_tags y document_tag_document. Funciona.
Un mes después hay que etiquetar clientes. Y luego expedientes, y luego cláusulas. Cada uno con su tabla de etiquetas y su tabla pivote, con la misma lógica de crear si no existe, el mismo generador de identificadores, el mismo selector en la interfaz duplicado cuatro veces.
Y llega el día que alguien pide filtrar por una etiqueta que existe en dos sitios, y descubres que "urgente" en documentos y "urgente" en expedientes son dos filas de dos tablas distintas que no se pueden cruzar.
Dos tablas, y la taxonomía es una columna de texto
La estructura que resuelve esto son exactamente dos tablas, y ninguna crece cuando añades un modelo nuevo.
terms guarda los términos: su nombre, su identificador estable, a qué taxonomía pertenecen, su padre si hay jerarquía y su orden.
termables es la pivote polimórfica: qué término está adjunto a qué modelo, sea del tipo que sea.
Y la decisión que más simplifica el día a día: la taxonomía no tiene tabla. Es una cadena de texto en una columna, y su definición vive en la configuración de la aplicación:
'taxonomies' => [
'case_tags' => ['models' => [CaseModel::class], 'max_terms_per_model' => 8],
'clause_tags'=> ['models' => [Clause::class]],
'thread_categories' => ['models' => [Thread::class], 'hierarchical' => true],
],
Añadir una clasificación nueva es una entrada en un array. Cero migraciones, cero despliegues de esquema, y la lista de taxonomías del proyecto se lee de un vistazo en un fichero.
NULL no sirve como valor de "sin propietario" en una restricción de unicidad
En SQL, NULL no es un valor: es la ausencia de valor, y dos ausencias no son iguales entre sí. Esa regla, que suena académica, es la que hace que una restricción de unicidad deje pasar duplicados exactamente donde menos te conviene.
Si tu aplicación tiene varios clientes o varias organizaciones, cada uno necesita su propio catálogo de etiquetas. La etiqueta "VIP" de la organización 5 y la "VIP" de la organización 6 son dos filas distintas, y tienen que poder coexistir con el mismo identificador.
Eso se resuelve con un segundo eje polimórfico en la tabla de términos, que dice a quién pertenece el catálogo, y con una restricción de unicidad sobre las cuatro columnas juntas:
UNIQUE (scope_type, scope_id, taxonomy, handle)
Y aquí está la trampa. Las etiquetas globales, las que no pertenecen a nadie, parecen pedir NULL en esas dos columnas. No lo hagas. En MySQL, NULL no es igual a NULL a efectos de una restricción de unicidad, así que la restricción deja de proteger justo en el caso global: puedes insertar "urgente" global cincuenta veces y la base de datos no dirá nada.
La solución es usar valores centinela: cadena vacía y cero.
protected $attributes = [
'scope_type' => '',
'scope_id' => 0,
];
Feo de leer y correcto de comportamiento. Es una de esas cosas que solo se descubren en producción, cuando aparecen los duplicados que la restricción debía haber impedido.
El pivote polimórfico solo puede tener cascada por un lado
Una relación polimórfica renuncia a algo a cambio de su flexibilidad, y conviene saber a qué: una clave foránea necesita apuntar a una tabla concreta, y una columna que hoy apunta a documentos y mañana a expedientes no puede tenerla.
En la tabla pivote, la columna que apunta al término sí puede tener clave foránea con borrado en cascada: borras un término y sus asociaciones se van solas, lo hace la base de datos.
La columna que apunta al modelo etiquetado no puede tenerla, porque es polimórfica: apunta a documents, a clients o a cases según la fila, y una clave foránea solo puede apuntar a una tabla.
Así que ese lado hay que limpiarlo desde el código, en el evento de borrado del modelo:
static::deleting(function ($model) {
if (method_exists($model, 'isForceDeleting') && ! $model->isForceDeleting()) {
return; // borrado suave: conservamos para poder restaurar
}
$model->terms()->detach();
});
Y hay un caso que ese enganche no cubre y conviene tener presente: si el modelo se borra por una cascada de la propia base de datos, porque su tabla padre tenía una clave foránea en cascada, el evento de Eloquent no se dispara y las filas del pivote quedan apuntando a un registro que ya no existe.
No es un fallo del diseño polimórfico, es su precio. Y la forma de pagarlo es acordarse: cada tabla que se relaciona de forma polimórfica con algo que se borra en cascada necesita su propia limpieza.
Si tu modelo usa borrado suave, no quites las etiquetas al borrar. Deja las asociaciones y quítalas solo en el borrado definitivo. Así, cuando alguien restaura un registro, recupera también su clasificación. Es un detalle de dos líneas que evita la conversación incómoda de "he recuperado el expediente pero ha perdido todas sus etiquetas".
El nombre en texto plano y las traducciones aparte
Guardar el nombre dos veces, en una columna y además traducido, se parece bastante a duplicar datos, que es justo lo que uno intenta evitar al diseñar un esquema. La justificación no está en el modelo, está en lo que la base de datos sabe hacer con cada formato.
Un término multiidioma se puede guardar entero en una columna JSON, con una clave por idioma. Es limpio conceptualmente y tiene un coste concreto: ordenar, indexar y filtrar por nombre pasa a requerir extraer del JSON en cada consulta.
La alternativa es tener las dos cosas: una columna de texto plano con el nombre canónico, que es la que usan el índice, el ORDER BY y la generación del identificador, y una columna JSON aparte con las traducciones, que un accesor devuelve cuando existe la del idioma activo.
Y para buscar en todos los idiomas a la vez, una tercera columna denormalizada que concatena el nombre, todas sus traducciones y las descripciones, mantenida automáticamente al guardar. Con eso, una sola búsqueda encuentra el término esté en el idioma que esté, sin condicionales por idioma en la consulta.
El árbol se construye en memoria con una sola consulta
Si la taxonomía es jerárquica, la trampa clásica es recorrer los hijos de cada nodo, que es una consulta por nivel y por rama.
No hace falta. Se traen todos los términos de la taxonomía en una consulta, se agrupan por identificador de padre en memoria y se enganchan:
$byParent = $terms->groupBy('parent_id');
$terms->each(fn ($term) => $term->setRelation(
'children',
$byParent->get($term->id, collect())
));
Una consulta, el árbol montado, y a partir de ahí la miga de pan y los descendientes se resuelven sin volver a la base de datos. Para una taxonomía de unos cientos de términos, que es lo normal, esto es siempre la respuesta correcta.
Desactivar no es borrar, y confundirlos rompe el histórico
Cuando alguien pide poder "quitar" una etiqueta, casi nunca quiere decir borrarla. Quiere que desaparezca de donde se elige, sin que se evapore de los cientos de registros que ya la llevan, y esas dos cosas son operaciones distintas aunque el botón se llame igual.
Cuando una etiqueta deja de usarse, lo que quieres normalmente no es borrarla: quieres que deje de aparecer en el selector para clasificaciones nuevas, pero que los registros que ya la tienen la sigan mostrando.
Eso es una bandera de activo, no un borrado suave, y son cosas distintas. El borrado suave dice "esto ya no existe"; la bandera dice "esto existe y no se ofrece". Meter ambas semánticas en el mismo mecanismo hace que nadie sepa qué significa una fila con fecha de borrado.
Qué hay en el mercado y en qué se diferencia
Etiquetar es de los problemas mejor resueltos del ecosistema de Laravel, así que la pregunta no es si existe algo hecho, sino en qué se diferencia de lo que necesitas. Las diferencias no están en las funcionalidades de la portada, están en el esquema.
spatie/laravel-tags es, con diferencia, el más usado: más de doce millones de instalaciones. Comparte la arquitectura de fondo, dos tablas y una pivote polimórfica, y soporta tipos de etiqueta y traducciones. La diferencia de enfoque que merece la pena evaluar es que su internacionalización va toda por JSON, apoyada en una dependencia de traducción obligatoria, lo cual es una sola fuente de verdad por campo a cambio de tener que extraer del JSON para ordenar o indexar. Y el aislamiento por cliente no está en su esquema, así que si lo necesitas se resuelve fuera, con un ámbito global o una base de datos por cliente.
rtconner/laravel-tagging y cviebrock/eloquent-taggable cubren el caso clásico de etiquetas libres, con normalización de identificadores y contadores de uso, y lo hacen bien. Su alcance no incluye de serie ni múltiples taxonomías ni jerarquía.
Lo que decidió nuestro caso fue el segundo eje polimórfico, el del propietario del catálogo, porque estaba en el esquema y en la restricción de unicidad en vez de resolverse a mano en cada consulta. Cuando tienes clientes que no deben verse las etiquetas entre sí, esa garantía prefieres tenerla en la base de datos.
Cómo se ve en producción
En Crowd Legal hay nueve taxonomías declaradas y seis modelos clasificables: expedientes, clientes, usuarios, hilos, cláusulas y plantillas de documento.
Y hay un séptimo que ilustra bien por qué el diseño polimórfico paga. El modelo de ficheros viene de otro paquete y no se puede modificar, así que la relación se le añade en tiempo de arranque desde un proveedor de servicios. No hizo falta ninguna tabla nueva ni tocar código ajeno: la pivote ya sabe apuntar a cualquier cosa.
Y si no trabajas con Laravel
Los criterios son de modelado de datos y valen en cualquier sitio:
- Una pivote polimórfica en vez de una tabla por modelo. Si añadir una entidad clasificable implica una migración, el diseño va a envejecer mal.
- Nada de NULL en las columnas de una restricción de unicidad. Usa un valor centinela, aunque quede feo.
- Limpia a mano el lado polimórfico al borrar, porque la base de datos no puede hacerlo por ti.
- Guarda el valor canónico en texto plano y las traducciones aparte, si necesitas ordenar o indexar por él.
- Y construye los árboles en memoria con una consulta, no recorriendo niveles.
Dónde está el código
El paquete está en Packagist, el código en GitHub y la documentación del paquete recoge las taxonomías, los ámbitos y los scopes de consulta. Son dos tablas y unas mil setecientas líneas.
Si tienes un producto donde la clasificación se ha ido repartiendo por columnas de texto y arrays JSON en distintos modelos, unificarlo es más barato de lo que parece y se puede hacer modelo a modelo. Lo trabajamos en desarrollo Laravel y software legal, y si nos enseñas cómo clasificas hoy te decimos por dónde empezar.