Curso de Laravel
Eventos y listeners en Laravel: desacoplar lo que pasa después
Por Víctor Peña · Publicado el
Hola, ¿cómo están? Hoy veremos un patrón que sirve para un problema muy concreto: cuando una acción tiene que desencadenar varias cosas que no tienen nada que ver entre sí.
Registrar una cita debería enviar un correo, avisar al doctor, actualizar una estadística y registrar una auditoría. Si todo eso vive en el controlador, ese controlador crece sin parar y cada nueva funcionalidad lo hace más difícil de leer.
Los eventos separan «qué pasó» de «qué hacer al respecto».
¡Empecemos!
El problema
public function store(GuardarCitaRequest $request)
{
$cita = Cita::create($request->validated());
Mail::to($cita->paciente->correo)->queue(new CitaAgendada($cita));
$cita->doctor->notify(new NuevaCitaAsignada($cita));
Estadistica::incrementar('citas_mes', $cita->fecha->month);
Auditoria::registrar('cita_creada', $cita, auth()->user());
if ($cita->paciente->esPrimeraVez()) {
Mail::to($cita->paciente->correo)->queue(new BienvenidaPaciente($cita->paciente));
}
return redirect()->route('citas.show', $cita);
}
Este controlador hace demasiado, y cada requisito nuevo añade otra línea aquí. Peor: si mañana se agendan citas desde otro sitio —una API, un comando, una importación— hay que repetir todo el bloque.
La alternativa
public function store(GuardarCitaRequest $request)
{
$cita = Cita::create($request->validated());
CitaAgendada::dispatch($cita);
return redirect()->route('citas.show', $cita);
}
El controlador anuncia lo que pasó. Quien tenga que reaccionar, que se apunte.
Y lo importante: agendar una cita desde cualquier otro sitio dispara exactamente lo mismo, sin repetir nada.
Crear el evento
php artisan make:event CitaAgendada
<?php
namespace App\Events;
use App\Models\Cita;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CitaAgendada
{
use Dispatchable, SerializesModels;
public function __construct(
public Cita $cita,
) {}
}
El evento es solo un contenedor de datos. No tiene lógica: dice qué pasó y con qué.
Nómbralos en pasado —CitaAgendada, PacienteRegistrado, PagoConfirmado— porque describen algo que ya ocurrió.
Crear los listeners
php artisan make:listener EnviarCorreoConfirmacion --event=CitaAgendada
<?php
namespace App\Listeners;
use App\Events\CitaAgendada;
use Illuminate\Contracts\Queue\ShouldQueue;
class EnviarCorreoConfirmacion implements ShouldQueue
{
public function handle(CitaAgendada $evento): void
{
Mail::to($evento->cita->paciente->correo)
->send(new \App\Mail\CitaAgendada($evento->cita));
}
}
Ese implements ShouldQueue es la funcionalidad que más rentabiliza los eventos: el listener se ejecuta en segundo plano, con toda la maquinaria de colas que ya vimos, y el usuario no espera.
Un listener sin ShouldQueue se ejecuta en la misma petición.
Mi criterio: todo lo que hable con el exterior —correos, APIs, archivos— va en cola. Lo que solo toca la base de datos y es rápido, en línea.
Registrar el evento
En las versiones actuales de Laravel no hay que registrar nada: si el listener está en app/Listeners/ y su método handle() declara el tipo del evento, se descubre solo.
Para ver qué está registrado:
php artisan event:list
Si prefieres declararlo explícitamente, en AppServiceProvider:
Event::listen(CitaAgendada::class, EnviarCorreoConfirmacion::class);
Y una función anónima también sirve para algo simple:
Event::listen(function (CitaAgendada $evento) {
Log::info("Cita {$evento->cita->id} agendada");
});
Despachar
CitaAgendada::dispatch($cita);
event(new CitaAgendada($cita)); // equivalente
CitaAgendada::dispatchIf($enviarAviso, $cita);
Todos los listeners se ejecutan, en el orden en que se registraron. Si uno falla y está en cola, los demás siguen funcionando: cada uno es un trabajo independiente.
Eso es una ventaja concreta sobre el controlador con todo dentro, donde un fallo en el correo puede tumbar la operación entera.
Eventos de modelos: lo que ya está disponible
Eloquent dispara eventos por su cuenta, sin que crees nada:
retrieved, creating, created, updating, updated,
saving, saved, deleting, deleted,
restoring, restored, replicating, trashed, forceDeleted
Se pueden escuchar directamente en el modelo:
protected static function booted(): void
{
static::creating(function (Cita $cita) {
$cita->codigo = 'CITA-' . strtoupper(Str::random(8));
$cita->creado_por = auth()->id();
});
static::deleting(function (Cita $cita) {
$cita->documentos()->each->delete();
});
}
Devolver false en un evento -ing cancela la operación:
static::deleting(function (Cita $cita) {
if ($cita->estado === 'atendida') {
return false;
}
});
Las parejas son claras: creating/created, updating/updated. Los -ing ocurren antes de escribir y pueden modificar o cancelar; los -ed, después.
Y saving/saved se disparan tanto al crear como al actualizar.
Observers: los eventos del modelo, ordenados
Cuando el modelo acumula varios de esos bloques, conviene sacarlos:
php artisan make:observer CitaObserver --model=Cita
<?php
namespace App\Observers;
use App\Models\Cita;
class CitaObserver
{
public function creating(Cita $cita): void
{
$cita->codigo = 'CITA-' . strtoupper(Str::random(8));
}
public function created(Cita $cita): void
{
Estadistica::incrementar('citas_mes', $cita->fecha->month);
}
public function updated(Cita $cita): void
{
if ($cita->wasChanged('estado')) {
Auditoria::registrar('cambio_estado', $cita);
}
}
public function deleted(Cita $cita): void
{
Log::info("Cita {$cita->codigo} eliminada");
}
}
Se registra con un atributo en el modelo:
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[ObservedBy(CitaObserver::class)]
class Cita extends Model
{
// ...
}
Ese wasChanged('estado') es el que vimos en la lección de actualizar registros, y en un observer es donde más sentido tiene.
La advertencia importante sobre los observers
Los eventos del modelo no se disparan en operaciones masivas.
Cita::where('fecha', '<', now())->update(['estado' => 'vencida']);
Cita::whereIn('id', $ids)->delete();
Esas dos consultas van directas a la base de datos y ningún observer se entera. Si tu auditoría depende del observer, esas operaciones no quedan registradas.
Cuando necesitas los eventos, hay que recorrer los registros:
Cita::where('fecha', '<', now())->each->update(['estado' => 'vencida']);
Es más lento, pero dispara todo. Elegir conscientemente cuál usar es lo importante.
Cuándo eventos y cuándo no
Y aquí viene la parte honesta, porque los eventos se usan de más.
El principal inconveniente es que ocultan el flujo. Al leer el controlador ves CitaAgendada::dispatch($cita) y no tienes ni idea de qué va a pasar. Hay que buscar los listeners, y si alguien añade uno nuevo, el comportamiento cambia sin que se note en ningún lado.
Eso está bien cuando lo que pasa después es realmente secundario. Está mal cuando es parte esencial de la operación.
Usa eventos cuando:
- Varias cosas independientes reaccionan a lo mismo
- Lo que pasa después es accesorio: avisos, estadísticas, auditoría
- La acción se dispara desde varios sitios
- Quieres que módulos distintos no se conozcan entre sí
No los uses cuando:
- Solo hay una cosa que hacer después. Llámala directamente.
- Es parte del flujo principal. Cobrar un pago no debería estar en un listener.
- El orden importa mucho. Para eso está
Bus::chain(). - Necesitas el resultado de lo que hace el listener.
Mi regla: si al leer el controlador necesitas saber qué pasa después para entenderlo, ponlo ahí. Si lo que pasa después es «además, avisar a alguien», va en un listener.
Es lo mismo que dije con las interfaces en la lección de inyección de dependencias: la indirección se paga con legibilidad, y solo compensa cuando resuelve algo real.
Un subscriber para agrupar
Cuando una clase escucha varios eventos relacionados:
class AuditoriaSubscriber
{
public function subscribe(Dispatcher $eventos): array
{
return [
CitaAgendada::class => 'registrarCita',
CitaCancelada::class => 'registrarCancelacion',
PacienteRegistrado::class => 'registrarPaciente',
];
}
public function registrarCita(CitaAgendada $evento): void { /* ... */ }
}
Toda la auditoría en un archivo, en lugar de un listener por evento.
Probar sin efectos
Event::fake();
$this->post('/citas', $datos);
Event::assertDispatched(CitaAgendada::class, function ($evento) {
return $evento->cita->paciente_id === $this->paciente->id;
});
Y para probar solo algunos:
Event::fake([CitaAgendada::class]);
Los modelos también permiten silenciar sus eventos:
Cita::withoutEvents(function () {
Cita::factory()->count(1000)->create();
});
Muy útil al sembrar datos de prueba, donde no quieres disparar mil correos.
Eventos del framework
Laravel dispara los suyos, y a veces son útiles:
Event::listen(function (\Illuminate\Auth\Events\Login $evento) {
$evento->user->update(['ultimo_acceso' => now()]);
});
Event::listen(function (\Illuminate\Auth\Events\Failed $evento) {
Log::warning('Intento fallido', ['email' => $evento->credentials['email']]);
});
Ese registro de intentos fallidos complementa el límite de intentos de la lección de autenticación.
Errores comunes
- Eventos para una sola cosa que podría llamarse directamente.
- Lógica esencial en un listener, donde nadie la encuentra.
- Listeners lentos sin
ShouldQueue. - Depender de observers en operaciones masivas que no los disparan.
- Un evento que dispara otro que dispara otro, hasta que nadie entiende el flujo.
- Despachar dentro de una transacción sin tener en cuenta el
after_commit. - Listeners que dependen del orden de ejecución.
Para cerrar
Los eventos son una herramienta buena para un problema concreto: varias reacciones independientes a un mismo hecho. Usados así, mantienen los controladores limpios y permiten añadir funcionalidad sin tocar lo que ya funciona.
Usados de más, convierten el código en algo imposible de seguir. La indirección tiene un costo, y hay que cobrarla solo cuando compensa.
Para lo cotidiano de un sistema de gestión, los observers de Eloquent cubren la mayoría de casos con mucho menos ceremonia.
En la siguiente lección veremos cómo construir una API REST.
Saludos y éxitos.
Norvic Software
Desarrollamos el software que tu empresa necesita
Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.
- Chatbots con Inteligencia Artificial
- Creación de agentes de IA
- Integración de IA en tus sistemas
- Desarrollo de software a medida
- Aplicaciones móviles iOS y Android
- Consultoría y asesoramiento técnico
Cotización sin costo · Respuesta directa por WhatsApp