Skip to content

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=tls

Crear un Mailable ​

bash
php artisan make:mail OrderShipped --markdown=mail.orders.shipped
php
<?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=database

Opciones: 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 GenerateMonthlyReport
php
<?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:listen

Error 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=UserRegistered
php
<?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:list

Disparar 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 TaskAssigned
php
<?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 minuto

En producción, un único cron llama al scheduler cada minuto:

bash
* * * * * cd /var/www/miapp && php artisan schedule:run >> /dev/null 2>&1

Buena 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 que dailyAt('08:00') sea a las 8 de su hora.
  • Usa QUEUE_CONNECTION=sync temporalmente si necesitas depurar un Job paso a paso.

Ejercicios ​

  1. Crea un Mailable ContactReceived que se envíe (encolado) al dueño del sitio cuando alguien llene el formulario de contacto, con el nombre, correo y mensaje del visitante y replyTo al correo del visitante.
  2. Crea un evento ProjectCompleted con 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 ShouldQueue para enviar en segundo plano; procesa con php artisan queue:work.
  • Jobs: tareas pesadas con reintentos ($tries), timeout y failed().
  • 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.

Academia Web — aprende, copia y construye.