Tema
Correos, colas y eventos
Enviar un correo de bienvenida, una notificación de pedido o generar un PDF pesado son tareas que no deberían hacer esperar al usuario. En esta lección aprenderás a enviar correos con Mailables, a mandarlos en segundo plano con colas (queues), a desacoplar tu código con eventos y listeners, y a programar tareas con el scheduler. Es una introducción práctica a lo que usarás en casi todo proyecto profesional.
Concepto
Imagina una taquería:
- Sin colas: el mesero toma tu orden, va a la cocina, prepara los tacos él mismo y hasta entonces atiende a la siguiente persona. Todos esperan.
- Con colas: el mesero apunta la orden, la pega en la barra (la cola) y sigue atendiendo. Un cocinero (el worker) va tomando las órdenes y las prepara.
En Laravel, un Job es la orden, la cola es la barra (una tabla en la BD, Redis, etc.) y el worker es un proceso que corre php artisan queue:work.
Los eventos son como un altavoz: "¡Nuevo pedido!". Varios listeners reaccionan sin que el código que gritó tenga que conocerlos: uno envía el correo, otro avisa por Slack, otro actualiza el inventario.
Parte 1: correos con Mailables
Configurar el envío
En desarrollo, lo más cómodo es no enviar nada real:
env
# Opción 1: los correos se escriben en storage/logs/laravel.log
MAIL_MAILER=log
# Opción 2 (recomendada): Mailpit / Mailtrap para ver los correos con diseño
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="no-reply@miapp.com"
MAIL_FROM_NAME="${APP_NAME}"Laravel Herd (versión Pro) y Sail incluyen Mailpit; también puedes usar una bandeja de pruebas de Mailtrap.
En producción usa un servicio real (SMTP de tu hosting, Amazon SES, Resend, Postmark, Mailgun):
env
MAIL_MAILER=smtp
MAIL_HOST=smtp.tuhosting.com
MAIL_PORT=587
MAIL_USERNAME=no-reply@cliente.com
MAIL_PASSWORD=contraseña-del-buzon
MAIL_SCHEME=tlsCrear un Mailable
bash
php artisan make:mail OrderShipped --markdown=mail.orders.shippedphp
<?php
namespace App\Mail;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
// ShouldQueue: este correo SIEMPRE se envía en segundo plano
class OrderShipped extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
// Las propiedades públicas están disponibles en la vista
public function __construct(public Order $order)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: "Tu pedido #{$this->order->id} va en camino",
replyTo: ['soporte@miapp.com'],
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.orders.shipped',
with: [
'url' => route('orders.show', $this->order),
],
);
}
public function attachments(): array
{
// Adjuntar un archivo del disco local, si existe
return $this->order->invoice_path
? [Attachment::fromStorageDisk('local', $this->order->invoice_path)->as('factura.pdf')]
: [];
}
}La vista Markdown resources/views/mail/orders/shipped.blade.php usa componentes de correo que se ven bien en todos los clientes de email:
blade
<x-mail::message>
# ¡Hola, {{ $order->user->name }}!
Tu pedido **#{{ $order->id }}** ya salió de nuestro almacén.
<x-mail::table>
| Producto | Cantidad | Precio |
|:---------|:--------:|-------:|
@foreach ($order->items as $item)
| {{ $item->name }} | {{ $item->quantity }} | ${{ number_format($item->price, 2) }} |
@endforeach
</x-mail::table>
<x-mail::button :url="$url">
Ver mi pedido
</x-mail::button>
Gracias por tu compra,<br>
{{ config('app.name') }}
</x-mail::message>Enviar el correo
php
use App\Mail\OrderShipped;
use Illuminate\Support\Facades\Mail;
// Como el Mailable implementa ShouldQueue, esto lo encola automáticamente
Mail::to($order->user)->send(new OrderShipped($order));
// Con copia
Mail::to($order->user)
->cc('ventas@miapp.com')
->bcc('auditoria@miapp.com')
->send(new OrderShipped($order));
// Encolar explícitamente (aunque el Mailable no implemente ShouldQueue)
Mail::to('cliente@example.com')->queue(new OrderShipped($order));
// Retrasar 10 minutos
Mail::to($user)->later(now()->addMinutes(10), new OrderShipped($order));Para ver el correo en el navegador mientras lo diseñas:
php
// routes/web.php (sólo en local)
if (app()->isLocal()) {
Route::get('/preview/mail', fn () => new App\Mail\OrderShipped(App\Models\Order::first()));
}Parte 2: colas
Configurar la cola
Laravel 11/12 viene con la cola en base de datos por defecto (la tabla jobs ya está en las migraciones):
env
QUEUE_CONNECTION=databaseOpciones: sync (ejecuta al instante, sin cola: útil para depurar), database (sencilla, funciona en casi cualquier hosting), redis (rápida, para mucho volumen).
Crear un Job
bash
php artisan make:job GenerateMonthlyReportphp
<?php
namespace App\Jobs;
use App\Mail\MonthlyReportReady;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Throwable;
class GenerateMonthlyReport implements ShouldQueue
{
use Queueable;
public int $tries = 3; // reintentos si falla
public int $timeout = 120; // segundos máximos de ejecución
public array $backoff = [10, 60]; // espera entre reintentos
// El modelo se serializa sólo con su ID y se vuelve a cargar en el worker
public function __construct(public User $user, public string $month)
{
}
public function handle(): void
{
// Tarea pesada: consultar, armar CSV, guardar
$rows = $this->user->orders()
->whereMonth('created_at', substr($this->month, 5, 2))
->whereYear('created_at', substr($this->month, 0, 4))
->get(['id', 'total', 'created_at']);
$csv = "id,total,fecha\n";
foreach ($rows as $row) {
$csv .= "{$row->id},{$row->total},{$row->created_at->format('Y-m-d')}\n";
}
$path = "reports/{$this->user->id}/{$this->month}.csv";
Storage::disk('local')->put($path, $csv);
Mail::to($this->user)->send(new MonthlyReportReady($path));
}
// Se ejecuta si fallan todos los intentos
public function failed(?Throwable $exception): void
{
logger()->error('Falló el reporte mensual', [
'user' => $this->user->id,
'error' => $exception?->getMessage(),
]);
}
}Despachar el Job
php
use App\Jobs\GenerateMonthlyReport;
public function requestReport(Request $request)
{
// Responde al instante; el reporte se genera en segundo plano
GenerateMonthlyReport::dispatch($request->user(), '2026-09');
return back()->with('status', 'Estamos generando tu reporte. Te llegará por correo.');
}
// Variantes
GenerateMonthlyReport::dispatch($user, $mes)->delay(now()->addMinutes(5));
GenerateMonthlyReport::dispatch($user, $mes)->onQueue('reports'); // cola específica
GenerateMonthlyReport::dispatchSync($user, $mes); // sin cola (al momento)Ejecutar el worker
bash
# Procesa trabajos continuamente (déjalo abierto en una terminal)
php artisan queue:work
# Con colas prioritarias: primero "high", luego "default"
php artisan queue:work --queue=high,default
# Ver y reintentar trabajos fallidos (tabla failed_jobs)
php artisan queue:failed
php artisan queue:retry all
# En desarrollo, queue:listen recarga el código en cada trabajo
php artisan queue:listenError común
"Encolé el correo y nunca llegó". Si QUEUE_CONNECTION=database, los trabajos se quedan en la tabla jobs hasta que un worker los procese. En desarrollo abre una terminal con php artisan queue:work. En producción usa Supervisor (ver Despliegue).
Error común
Cambiar el código de un Job y no ver el cambio: queue:work carga el código una sola vez. Reinícialo (php artisan queue:restart) después de cada despliegue.
Parte 3: eventos y listeners
Cuando se registra un usuario quieres: enviar bienvenida, crear su carpeta de documentos y avisar al equipo de ventas. En vez de meter todo en el controlador, disparas un evento.
bash
php artisan make:event UserRegistered
php artisan make:listener SendWelcomeEmail --event=UserRegistered
php artisan make:listener NotifySalesTeam --event=UserRegisteredphp
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserRegistered
{
use Dispatchable, SerializesModels;
public function __construct(public User $user)
{
}
}php
<?php
namespace App\Listeners;
use App\Events\UserRegistered;
use App\Mail\WelcomeMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
// ShouldQueue: el listener corre en segundo plano
class SendWelcomeEmail implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
Mail::to($event->user)->send(new WelcomeMail($event->user));
}
}php
<?php
namespace App\Listeners;
use App\Events\UserRegistered;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Http;
class NotifySalesTeam implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
// Webhook de Slack/Discord configurado en .env -> config/services.php
Http::post(config('services.slack.webhook'), [
'text' => "Nuevo registro: {$event->user->name} ({$event->user->email})",
]);
}
}En Laravel 11/12 los listeners se descubren automáticamente: si el método handle recibe el evento tipado, queda conectado. No hay que registrar nada. Compruébalo con:
bash
php artisan event:listDisparar el evento:
php
use App\Events\UserRegistered;
$user = User::create($data);
UserRegistered::dispatch($user);
// o: event(new UserRegistered($user));Parte 4: notificaciones (correo + base de datos)
Las notificaciones envían el mismo aviso por varios canales:
bash
php artisan make:notification TaskAssignedphp
<?php
namespace App\Notifications;
use App\Models\Task;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TaskAssigned extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Task $task)
{
}
// Canales: correo y la tabla "notifications" (php artisan make:notifications-table)
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Te asignaron una tarea')
->greeting("Hola, {$notifiable->name}")
->line("Nueva tarea: {$this->task->title}")
->action('Ver tarea', route('tasks.show', $this->task))
->line('¡Éxito!');
}
public function toArray(object $notifiable): array
{
return ['task_id' => $this->task->id, 'title' => $this->task->title];
}
}php
$user->notify(new TaskAssigned($task));
// En una vista: notificaciones sin leer
$user->unreadNotifications; // colección
$user->unreadNotifications->markAsRead();Parte 5: tareas programadas (scheduler)
En Laravel 11/12 se definen en routes/console.php:
php
<?php
use App\Jobs\GenerateMonthlyReport;
use App\Models\User;
use Illuminate\Support\Facades\Schedule;
// Limpiar trabajos fallidos viejos cada día
Schedule::command('queue:prune-failed --hours=168')->daily();
// Recordatorio diario a las 8:00 (hora de config('app.timezone'))
Schedule::call(function () {
User::whereHas('tasks', fn ($q) => $q->whereDate('due_date', today()))
->each(fn ($user) => $user->notify(new \App\Notifications\TasksDueToday()));
})->dailyAt('08:00');
// El día 1 de cada mes, encolar reportes
Schedule::call(function () {
$mes = now()->subMonth()->format('Y-m');
User::each(fn ($u) => GenerateMonthlyReport::dispatch($u, $mes));
})->monthlyOn(1, '06:00');bash
php artisan schedule:list # ver tareas y próxima ejecución
php artisan schedule:work # en desarrollo: ejecuta el scheduler cada minutoEn producción, un único cron llama al scheduler cada minuto:
bash
* * * * * cd /var/www/miapp && php artisan schedule:run >> /dev/null 2>&1Buena práctica
- Todo lo que tarde más de ~1 segundo o dependa de un servicio externo (correo, APIs, PDFs) va a la cola.
- Pon
APP_TIMEZONE=America/Mexico_City(o la de tu cliente) para quedailyAt('08:00')sea a las 8 de su hora. - Usa
QUEUE_CONNECTION=synctemporalmente si necesitas depurar un Job paso a paso.
Ejercicios
- Crea un Mailable
ContactReceivedque se envíe (encolado) al dueño del sitio cuando alguien llene el formulario de contacto, con el nombre, correo y mensaje del visitante yreplyToal correo del visitante. - Crea un evento
ProjectCompletedcon un listener que envíe una notificación al cliente del proyecto.
Ver solución 1
php
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactReceived extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(public array $data)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Nuevo mensaje de contacto',
replyTo: [new Address($this->data['email'], $this->data['nombre'])],
);
}
public function content(): Content
{
return new Content(markdown: 'mail.contact-received');
}
}blade
<x-mail::message>
# Nuevo mensaje
**Nombre:** {{ $data['nombre'] }}
**Correo:** {{ $data['email'] }}
{{ $data['mensaje'] }}
</x-mail::message>php
// Controlador
Mail::to(config('mail.from.address'))->send(new ContactReceived($request->validated()));Ver solución 2
php
// app/Events/ProjectCompleted.php
class ProjectCompleted
{
use Dispatchable, SerializesModels;
public function __construct(public Project $project) {}
}
// app/Listeners/NotifyClientProjectCompleted.php
class NotifyClientProjectCompleted implements ShouldQueue
{
public function handle(ProjectCompleted $event): void
{
$event->project->client->notify(new ProjectCompletedNotification($event->project));
}
}
// Al marcar como completado
$project->update(['status' => 'completed']);
ProjectCompleted::dispatch($project);El modelo del cliente debe usar el trait Notifiable (el User ya lo trae).
Resumen
- Mailables con plantillas Markdown; configura
MAIL_*en.env(log/Mailpit en local). - Implementa
ShouldQueuepara enviar en segundo plano; procesa conphp artisan queue:work. - Jobs: tareas pesadas con reintentos (
$tries),timeoutyfailed(). - Eventos/listeners desacoplan acciones; se descubren automáticamente.
- Notificaciones envían por varios canales (mail, database...).
- Scheduler en
routes/console.php+ un cron cada minuto en producción.