Curso de Laravel
Envío de correos en Laravel: Mailables y notificaciones
Por Víctor Peña · Publicado el
Hola, ¿cómo están? Un sistema que agenda citas tarde o temprano tiene que avisar al paciente. Y ese aviso, en la mayoría de casos, es un correo.
Laravel tiene una de las mejores implementaciones de correo que he visto en un framework: escribes la plantilla en Blade, la pruebas en el navegador y el envío es una línea.
¡Empecemos!
Configurar el envío
En el archivo .env:
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=tu-correo@gmail.com
MAIL_PASSWORD=tu-contrasena-de-aplicacion
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="sistema@tuclinica.com"
MAIL_FROM_NAME="${APP_NAME}"
Dos advertencias sobre Gmail, que es lo que casi todos prueban primero:
No uses tu contraseña normal. Hay que generar una contraseña de aplicación desde la configuración de seguridad de la cuenta, con la verificación en dos pasos activada.
Y para un sistema en producción, Gmail no sirve. Tiene límites diarios bajos y los correos suelen terminar en spam. Para eso existen servicios de envío transaccional, que gestionan la reputación del remitente y te dan estadísticas de entrega. Para desarrollo y pruebas, Gmail está bien.
Probar sin enviar nada
Esto es lo primero que conviene configurar, antes que nada:
MAIL_MAILER=log
Con eso, los correos no se envían: se escriben en storage/logs/laravel.log. Puedes ver el contenido completo sin llenar de pruebas la bandeja de nadie.
Y si prefieres verlos renderizados, existen servicios que dan un buzón falso con credenciales SMTP: los correos llegan ahí y los ves como los vería el destinatario, pero nunca salen a internet.
Un correo de prueba enviado por error a una lista real es un problema del que cuesta salir. Merece la pena dedicar cinco minutos a esto.
Crear un Mailable
php artisan make:mail CitaAgendada --markdown=mail.cita-agendada
Eso genera la clase y una plantilla de Markdown ya maquetada.
<?php
namespace App\Mail;
use App\Models\Cita;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class CitaAgendada extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Cita $cita,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Tu cita del ' . $this->cita->fecha->translatedFormat('d \d\e F'),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.cita-agendada',
);
}
public function attachments(): array
{
return [];
}
}
Las propiedades públicas del constructor están disponibles en la plantilla automáticamente. No hay que pasarlas.
Ese translatedFormat() viene de la lección de traducciones; con format() el asunto diría «May».
La plantilla
resources/views/mail/cita-agendada.blade.php:
<x-mail::message>
# Hola, {{ $cita->paciente->nombre }}
Tu cita quedó agendada correctamente.
<x-mail::panel>
**Fecha:** {{ $cita->fecha->translatedFormat('l d \d\e F \d\e Y') }}
**Hora:** {{ $cita->fecha->format('H:i') }}
**Doctor:** {{ $cita->doctor->nombre }}
**Consultorio:** {{ $cita->consultorio }}
</x-mail::panel>
Por favor llega diez minutos antes.
<x-mail::button :url="route('citas.show', $cita)">
Ver los detalles
</x-mail::button>
Si necesitas cancelar o reprogramar, responde a este correo.
Gracias,
{{ config('app.name') }}
</x-mail::message>
Esos componentes generan un correo con una maqueta que funciona en todos los clientes, incluido Outlook, que es donde más se rompen los diseños.
Escribir HTML de correo a mano es de las cosas más ingratas de este oficio: tablas anidadas, estilos en línea, reglas distintas en cada cliente. Que Laravel lo resuelva es una ventaja real.
Para personalizar el diseño:
php artisan vendor:publish --tag=laravel-mail
Enviar
use Illuminate\Support\Facades\Mail;
Mail::to($cita->paciente->correo)->send(new CitaAgendada($cita));
Con más destinatarios:
Mail::to($cita->paciente->correo)
->cc($cita->doctor->correo)
->bcc('archivo@clinica.com')
->send(new CitaAgendada($cita));
Y si le pasas un modelo con las propiedades email y name, los usa directamente:
Mail::to($usuario)->send(new CitaAgendada($cita));
En el controlador:
public function store(GuardarCitaRequest $request)
{
$cita = Cita::create($request->validated());
Mail::to($cita->paciente->correo)->send(new CitaAgendada($cita));
return redirect()->route('citas.show', $cita)->with('exito', 'Cita agendada');
}
Ver el correo en el navegador
Esta es mi funcionalidad favorita de todo el sistema de correo:
Route::get('/probar-correo', function () {
return new App\Mail\CitaAgendada(App\Models\Cita::first());
});
Abres esa dirección y ves el correo renderizado en el navegador, con datos reales, recargando con F5 cada vez que cambias la plantilla.
Ajustar un correo enviándolo una y otra vez es desesperante. Así se hace en un minuto.
Recuerda quitar esa ruta antes de subir a producción, o protegerla:
if (! app()->isLocal()) {
abort(404);
}
Adjuntar archivos
public function attachments(): array
{
return [
Attachment::fromStorageDisk('public', $this->cita->comprobante)
->as('comprobante.pdf')
->withMime('application/pdf'),
];
}
Desde una ruta cualquiera:
Attachment::fromPath(storage_path('app/reglamento.pdf'));
O desde contenido generado al vuelo, que es lo que haremos con los reportes en PDF:
Attachment::fromData(fn () => $this->pdf, 'factura.pdf')
->withMime('application/pdf');
El problema del envío directo
Ese Mail::send() del controlador tiene un inconveniente serio: el usuario espera a que el correo salga.
Conectar con el servidor SMTP puede tardar entre uno y cinco segundos. Durante ese tiempo la página está cargando y el usuario no sabe si funcionó.
Y peor: si el servidor de correo está caído, la operación falla entera aunque la cita se haya guardado bien.
La solución es encolarlo:
Mail::to($cita->paciente->correo)->queue(new CitaAgendada($cita));
Un solo cambio: queue() en vez de send(). La respuesta al usuario es inmediata y el correo se envía en segundo plano.
Para que funcione hay que tener un proceso trabajador atendiendo la cola, que es la lección de colas. Y si tu conexión de cola es sync, queue() se comporta igual que send(), así que puedes escribirlo así desde ahora sin romper nada.
También se puede diferir:
Mail::to($paciente->correo)
->later(now()->addHours(24), new RecordatorioCita($cita));
Notificaciones: una capa más arriba
Un Mailable envía un correo. Una notificación representa un aviso que puede llegar por varios canales: correo, base de datos, mensajería.
php artisan make:notification CitaProxima
class CitaProxima extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Cita $cita) {}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Recordatorio de tu cita')
->greeting("Hola, {$notifiable->name}")
->line('Te recordamos que tienes una cita mañana.')
->line('Hora: ' . $this->cita->fecha->format('H:i'))
->action('Ver la cita', route('citas.show', $this->cita))
->line('Gracias por confiar en nosotros.');
}
public function toArray(object $notifiable): array
{
return [
'cita_id' => $this->cita->id,
'mensaje' => 'Tienes una cita mañana a las ' . $this->cita->fecha->format('H:i'),
];
}
}
$paciente->notify(new CitaProxima($cita));
Con ese via(), una sola llamada envía el correo y guarda el aviso en la base de datos para mostrarlo dentro del sistema. Añadir un canal más adelante es tocar un array.
Para el canal database:
php artisan make:notifications-table
php artisan migrate
Y en el modelo User, el trait Notifiable (que ya viene incluido).
Mostrar los avisos:
@foreach (auth()->user()->unreadNotifications as $aviso)
<li>{{ $aviso->data['mensaje'] }}</li>
@endforeach
auth()->user()->unreadNotifications->markAsRead();
¿Mailable o notificación? Si el aviso solo va por correo y tiene un diseño propio elaborado, un Mailable. Si es un aviso del sistema que podría necesitar otro canal, una notificación. En sistemas de gestión, las notificaciones cubren casi todo.
Envío masivo
Para mandar el mismo correo a muchas personas:
foreach ($pacientes as $paciente) {
Mail::to($paciente->correo)->queue(new Boletin);
}
Uno por uno, no todos en el to(). Poniendo cien direcciones juntas, cada destinatario ve las de los demás. Es una fuga de datos personales, y en un sistema de salud es un problema grave.
Y con muchos destinatarios, conviene limitar el ritmo para no saturar el servidor ni acabar marcado como spam. Con colas:
Mail::to($paciente->correo)
->later(now()->addSeconds($indice * 2), new Boletin);
Comprobar que se envía en las pruebas
use Illuminate\Support\Facades\Mail;
Mail::fake();
$this->post('/citas', $datos);
Mail::assertQueued(CitaAgendada::class, function ($correo) use ($paciente) {
return $correo->hasTo($paciente->correo);
});
Mail::fake() intercepta todo: nada sale de verdad y puedes verificar qué se habría enviado.
Cuando el envío falla
Un correo puede fallar por causas ajenas a tu código: el servidor caído, la casilla llena, la dirección inexistente.
Si lo envías encolado, el trabajo fallido queda registrado y puede reintentarse. Si lo envías directo, conviene al menos no tumbar la operación:
try {
Mail::to($cita->paciente->correo)->send(new CitaAgendada($cita));
} catch (\Throwable $e) {
report($e);
session()->flash('aviso', 'La cita se agendó, pero no pudimos enviar el correo.');
}
La cita ya está guardada; que falle el correo no debería parecer que falló todo.
Errores comunes
- Usar la contraseña normal de Gmail en lugar de una contraseña de aplicación.
- Probar contra correos reales en vez de
MAIL_MAILER=log. send()en el controlador y hacer esperar al usuario.- Cien direcciones en un solo
to(). - No manejar el fallo y tumbar una operación que sí funcionó.
- Olvidar
config:cleartras cambiar la configuración en.env. format()en vez detranslatedFormat()en asuntos y fechas.
Para cerrar
El correo en Laravel es de las partes mejor resueltas del framework: Blade para la plantilla, vista previa en el navegador y una línea para enviar.
Dos costumbres que valen mucho: MAIL_MAILER=log mientras desarrollas, y queue() en lugar de send() siempre que puedas.
Con esto cerramos el módulo de usuarios y archivos. En la siguiente lección veremos algo pequeño pero que resuelve la mitad de los «a mí no me funciona»: limpiar la caché.
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