Saltar al contenido
Ende Sun
Volver al blog

Backend y arquitectura

Contenido traducible en Laravel con columnas JSON y Filament

6 min de lectura

Contenido traducible en Laravel con columnas JSON y Filament

El punto de partida

Mi portfolio ya mostraba la interfaz en español, inglés y chino, pero el contenido en sí (proyectos, experiencia, mi propia presentación) era texto plano en un único idioma. Si elegías chino, se traducía el menú y todas las descripciones de proyectos seguían en inglés. Quería que cada texto público se pudiera editar por idioma desde el panel de administración, con un respaldo predecible cuando falta una traducción, y sin convertir una web pequeña en una plataforma de localización.

Por qué no un paquete ni tablas de traducciones

Hay dos opciones habituales. Una es un paquete como spatie/laravel-translatable. La otra es una tabla de traducciones por modelo, por ejemplo project_translations con una columna locale y una fila por idioma. Las dos son perfectamente válidas. Para este proyecto elegí un trait propio y pequeño sobre columnas JSON:

  • El volumen de contenido es mínimo: unas pocas decenas de proyectos y un puñado de artículos. Nunca necesito filtrar ni ordenar en SQL por un valor traducido.
  • Una fila por entidad mantiene sencillos el formulario del panel, el cargador de contenido y los tests: un campo traducible es solo un array {"en": "...", "es": "...", "zh": "..."}.
  • El comportamiento que necesitaba (un orden de respaldo explícito e ignorar valores vacíos) cabe en unas cuarenta líneas. Al ser mío, no hay sorpresas cuando se actualiza el framework.

La contrapartida es real: no se puede indexar ni ordenar de forma eficiente por un valor traducido, y añadir un cuarto idioma implica tocar datos en todas las filas en lugar de insertar filas nuevas. A esta escala es aceptable. Con miles de filas y consultas por idioma, usaría tablas de traducciones.

El trait

Cada modelo declara qué campos son traducibles, y el trait registra el cast y los accesores:

trait HasTranslations
{
    public function initializeHasTranslations(): void
    {
        foreach ($this->translatable as $field) {
            $this->casts[$field] = 'json:unicode';
        }
    }

    public function t(string $field, ?string $locale = null): ?string
    {
        $resolved = $this->localeOf($field, $locale);

        return $resolved ? trim(((array) $this->getAttribute($field))[$resolved]) : null;
    }

    public function localeOf(string $field, ?string $locale = null): ?string
    {
        $values = (array) $this->getAttribute($field);

        foreach ([$locale ?? app()->getLocale(), ...Locales::FALLBACK] as $candidate) {
            if (filled($values[$candidate] ?? null)) {
                return $candidate;
            }
        }

        return null;
    }
}

Algunos detalles importan más de lo que parece:

  • json:unicode guarda los caracteres chinos y las tildes como texto legible en vez de secuencias \u. Así los volcados y los diffs de la base de datos los puede revisar una persona.
  • El orden de respaldo es primero el idioma pedido y después inglés, español y chino. Es una constante, no configuración repartida por las vistas.
  • filled() ignora cadenas vacías y espacios, de modo que una pestaña en español vacía en el panel nunca gana a un texto real en inglés.
  • Si ningún idioma tiene texto, t() devuelve null, y las vistas simplemente omiten ese elemento en lugar de pintar una caja vacía o una clave de traducción.
  • localeOf() indica qué idioma se ha usado de verdad. El blog se apoya en él para mostrar el aviso «disponible solo en inglés» cuando un artículo recurre al respaldo.

Una pestaña por idioma en Filament

En el panel, cada campo traducible se convierte en un grupo de pestañas, una por idioma. Las construye un pequeño helper:

public static function make(string $field, Closure $component): Tabs
{
    return Tabs::make($field)
        ->tabs(array_map(
            fn (string $locale) => Tab::make(self::LABELS[$locale] ?? $locale)
                ->schema([$component("{$field}.{$locale}")]),
            Locales::ALL,
        ))
        ->columnSpanFull();
}

El closure recibe un nombre como title.es, y Filament asocia esa ruta al array anidado automáticamente gracias al cast JSON. No hay gestión de estado a medida: el mismo helper sirve para un campo de texto, un textarea o un editor Markdown. En las tablas, una columna resuelve el valor del idioma actual con el mismo respaldo mediante getStateUsing(fn ($record) => $record->t($field)).

Migrar los datos existentes sin perder nada

El requisito era conservar todo el contenido existente y tratarlo como la versión en inglés. La migración cambia el tipo de columna y después envuelve cada valor, y está escrita para poder ejecutarse más de una vez sin riesgo:

private function wrap(?string $value): ?string
{
    if ($value === null) {
        return null;
    }

    $decoded = json_decode($value, true);
    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded) && ! array_is_list($decoded)) {
        return $value; // ya es {"en": ...}
    }

    return json_encode(['en' => $value], JSON_UNESCAPED_UNICODE);
}

De este paso salieron dos lecciones.

Cambiar el tipo de una columna en SQLite reconstruye la tabla entera. Laravel crea una tabla temporal, copia los datos, borra la original y renombra la copia. Si otra tabla tiene una clave foránea con ON DELETE CASCADE hacia ella, ese borrado intermedio dispara la cascada y elimina las filas dependientes. Desactivar las claves foráneas no sirve dentro de una transacción, que es justo como se ejecutan los tests. La solución fue alterar una columna solo cuando su tipo no es ya el correcto, de modo que una segunda ejecución no reconstruye nada.

Envolver el texto antiguo como inglés es correcto, pero genera trabajo oculto. Esos valores siguen solo en inglés hasta que alguien los traduce, y el respaldo hace invisible el hueco: las páginas en español y en chino muestran inglés sin avisar. En esta web, veintiún textos de proyectos antiguos se quedaron así tras la migración, porque nada más volvía a tocar ese campo. Un script corto que lista cada campo traducible sin español o sin chino los encontró todos en minutos. Ahora hago esa auditoría después de cualquier cambio de este tipo.

Una búsqueda que ignora tildes pero no el chino

Los proyectos y los artículos se pueden buscar. Escribir «migracion» tiene que encontrar «Migración», y escribir «喉部» tiene que encontrar el título en chino del proyecto de laringe.

Mi primer intento usaba iconv con ASCII//TRANSLIT//IGNORE. En la plataforma del servidor convertía «Migración» en «Migraci'on»: la transliteración metía un apóstrofo donde estaba la tilde, así que la comparación fallaba. Además habría destrozado cualquier texto en chino. La versión final se basa en la descomposición Unicode:

public static function normalize(string $text): string
{
    $text = Normalizer::normalize(trim($text), Normalizer::FORM_D) ?: $text;
    $text = preg_replace('/\p{Mn}/u', '', $text);
    $text = mb_strtolower($text);

    return trim(preg_replace('/\s+/u', ' ', $text));
}

NFD separa «ó» en «o» más un acento combinable, y \p{Mn} elimina esas marcas sin espacio. Los caracteres chinos no se descomponen así, de modo que pasan intactos. Pasar a minúsculas resuelve las mayúsculas, y una simple búsqueda de subcadena funciona con el chino, que no separa las palabras con espacios. La misma normalización se aplica a la consulta y al texto indexado, en el servidor y otra vez en el filtro del navegador, para que ambos coincidan siempre.

Lo que mantendría

  • Unas reglas de respaldo pequeñas y explícitas son mejores que la magia ingeniosa. Cualquiera puede leer el trait en un minuto.
  • Las migraciones que cambian datos deben ser idempotentes y comprobar antes de alterar nada.
  • Después de migrar contenido, hay que auditar qué campos existen solo en el idioma de respaldo. El respaldo es una red de seguridad, no una traducción.