Los campos calculados son de las decisiones más pequeñas que se toman en un proyecto y de las que peor envejecen. Slug, referencia, título compuesto, texto de búsqueda: cada uno entra por su lado y todos acaban en el mismo sitio. Aquí verás por qué el observer no es ese sitio, por qué un accessor tampoco resuelve el problema cuando necesitas consultar el campo, y dónde colocarlos para que dejen de multiplicarse. Los ejemplos van en Laravel porque es una de nuestras especialidades, y comparamos con lo que el propio framework ya ofrece, pero el problema aparece igual en cualquier ORM con ciclo de vida.
El observer empieza limpio y acaba siendo el sitio donde cabe todo
El patrón es siempre el mismo. Necesitas generar un slug al crear un registro y lo pones en el observer, que para eso está. Luego hace falta un código de referencia. Luego un campo de texto concatenado para buscar. Luego un título comercial que se compone a partir de los dos anteriores.
Seis meses después el observer tiene ochenta líneas, mezcla lógica de negocio con orquestación y su método creating hace cinco cosas que no se parecen entre sí.
Y hay un síntoma que delata que ya se ha ido de las manos: abres dos observers de modelos distintos y encuentras la misma función privada de generar slugs, copiada byte a byte, con la única diferencia del texto de reserva cuando el resultado queda vacío. Ese día ya no es un problema de estilo, es duplicación real.
Un campo calculado que quieres consultar tiene que estar en una columna
Antes de mover nada conviene separar dos casos que la gente mezcla, porque tienen soluciones distintas.
Si el valor solo se muestra, un accessor es la respuesta correcta. Se calcula al leer, no ocupa espacio y no puede quedar desincronizado.
Pero si el valor se consulta, el accessor no vale. Un campo de búsqueda concatenado solo sirve si puedes escribir:
WHERE search_text LIKE '%martinez%'
Y eso exige que sea una columna real, con su índice. Lo mismo con un código único que necesita una restricción de unicidad, o con un slug por el que se resuelve una ruta.
Ahí es donde la conversación se pone interesante, porque el cálculo tiene que ocurrir en escritura y persistirse, y ese es justo el terreno del observer.
Sacar el cálculo a una clase con una convención de nombres
La solución que nos funciona es sacar cada cálculo a su propia clase, dejando en el observer solo la orquestación.
La clase recibe el modelo y expone un método por campo, con el nombre derivado del campo: search_text se resuelve con getSearchText, advert_title con getAdvertTitle. No hay registro de qué método corresponde a qué columna, la convención de nombres es el registro.
class UserKeeper
{
public function __construct(protected User $user) {}
public function getSearchText(): string
{
$parts = array_filter([
$this->user->name,
$this->user->last_name,
$this->user->email,
$this->user->phone,
$this->user->address,
$this->user->postal_code,
]);
return substr(implode(' ', $parts), 0, 1000);
}
}
Y el observer se queda en lo que de verdad le toca, que es decidir cuándo:
public function creating(User $user): void
{
$user->slug = Str::uuid();
$user->process(['search_text']);
Password::sendResetLink($user->only('email'));
}
La clase se asocia al modelo con un atributo, y el atributo es repetible, así que un modelo grande puede repartir sus cálculos en varias clases en vez de acumularlos en una.
Nombra los campos con guiones bajos y en minúscula, que es la convención de columnas de Laravel y la que usa la resolución por nombre. Si sigues esa convención, que es la que ya sigue cualquier proyecto Laravel normal, el método se encuentra solo y no hay nada más que configurar.
El orden del array es el orden de ejecución
Los campos calculados rara vez son independientes entre sí: el título comercial necesita el slug, y el texto de búsqueda necesita los dos. Cualquier sistema que los trate como un conjunto desordenado obliga a resolver esas dependencias a mano, y ese es justo el trabajo que se puede evitar.
Cuando pides varios campos a la vez, se calculan en el orden en que los escribes, y cada uno ya tiene disponible lo que calcularon los anteriores:
$property->process(['code', 'reference', 'advert_title']);
Aquí el código se genera primero, la referencia lo usa para componerse, y el título comercial usa la referencia. Invertir el array rompe el cálculo sin dar ningún error, así que ese orden es información y conviene tratarlo como tal.
Es exactamente el tipo de dependencia que en un observer largo queda enterrada entre otras veinte líneas y aquí queda en una sola línea legible.
Calcular y guardar son cosas distintas, y separarlas te da el relleno retroactivo
Decisión de diseño que parece una omisión y es lo contrario: calcular no guarda. Los valores se asignan en memoria y ya decides tú qué hacer con ellos.
Dentro de un observer eso es lo correcto, porque el guardado viene después de todas formas y persistir en medio provocaría una escritura extra o una recursión.
Pero lo bueno viene fuera. El día que añades un campo calculado a un modelo con cien mil registros existentes, el relleno retroactivo son tres líneas y reutiliza exactamente el mismo cálculo que usa el alta:
foreach (Client::cursor() as $client) {
$client->process(['search_text']);
$client->save();
}
Si el cálculo estuviera dentro del observer, para rellenar tendrías que tocar y volver a guardar cada registro solo para disparar el evento, o duplicar la lógica en el comando. Las dos opciones son peores.
Qué hay en Laravel para lo mismo y en qué se diferencia
Conviene ser honesto con esto: no aporta ninguna capacidad que Laravel no tenga. Aporta un sitio donde poner el código y una convención. Aun así, las alternativas nativas tienen compromisos concretos que merece la pena conocer.
Los accessors con la clase Attribute son la herramienta correcta para valores derivados que solo se muestran. Calculan al leer y no persisten, así que no se pueden consultar, indexar ni someter a una restricción de unicidad. Para un campo de búsqueda o un slug de ruta, no es una opción.
Los casts personalizados transforman en escritura y sí persisten, que es la mitad del problema resuelta. El compromiso está en la forma: son uno a uno entre campo y clase, y el cast recibe el valor y el modelo pero está pensado para transformar, no para componer a partir de otras columnas ni para lanzar consultas. Generar un código comprobando que no exista ya en la tabla se queda incómodo.
El atributo #[ObservedBy] es el equivalente nativo para asociar comportamiento a un modelo, y es la pieza correcta para decidir cuándo. Lo que no da es invocación campo a campo: un observer se dispara por eventos, así que no puedes pedirle "recalcula solo este campo" desde un comando.
Y la lógica directa en booted() o en el observer es el punto de partida, el que funciona perfectamente hasta que hay cuatro campos y dos modelos que comparten cálculo.
La diferencia práctica que a nosotros nos vale es que el observer se queda decidiendo cuándo y el cálculo se queda decidiendo cuánto vale, cada uno en su fichero, y el segundo es invocable desde donde quieras.
Cómo se ve en producción
En Abodara hay nueve modelos con campos calculados, nueve clases de cálculo y veintiún métodos repartidos entre ellas, invocados desde nueve observers y un comando de consola.
El caso más denso es el de ficheros, donde el tipo depende del MIME ya calculado y por eso van en llamadas separadas:
$file->process(['mime_type']);
if (empty($file->type)) $file->process(['type']);
$file->process(['slug', 'position', 'digest', 'size', 'access', 'default', 'published']);
Nueve campos calculados en un observer que sigue cabiendo en una pantalla.
Y si no trabajas con Laravel
El criterio se traslada tal cual y no depende del framework:
- Separa cuándo se calcula de cómo se calcula. El disparador y la fórmula son responsabilidades distintas y cambian por motivos distintos.
- Si el valor se consulta, persístelo en una columna. Calcular al leer es correcto solo para lo que se muestra.
- Que la función de cálculo sea invocable sola, sin depender del ciclo de vida del ORM. Es lo que te permite rellenar registros antiguos y probarla sin base de datos.
- Y haz explícitas las dependencias entre campos. Si uno se calcula a partir de otro, que se vea en una línea y no enterrado en el orden de aparición de un método largo.
Dónde está el código
El paquete está en Packagist, el código en GitHub y la documentación del paquete recoge la API completa. Son unas doscientas líneas, sin tablas, sin configuración y con un generador make:keeper para no escribir el esqueleto a mano.
Si tienes un proyecto donde los observers se han vuelto el cajón de sastre, esto se aplica de forma incremental: un modelo, un campo, y sigues. Lo trabajamos en desarrollo Laravel y aplicaciones web, y si nos enseñas el modelo que más duele te decimos por dónde empezar.