Skip to content

Notifications y jobs ​

El sistema asíncrono del backend: notifications (database + mail), queue worker y scheduler.

Notifications ​

HUMAE usa el sistema nativo de Laravel (Illuminate\Notifications\Notification). Las 11 notifications viven en app/Notifications/.

Anatomía de una Notification ​

php
<?php

declare(strict_types=1);

namespace App\Notifications;

use App\Models\Membership;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

final class MembershipActivatedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private readonly Membership $membership) {}

    public function via(mixed $notifiable): array
    {
        return ['database', 'mail'];
    }

    public function toMail(mixed $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Tu membresía HUMAE está activa 🎉')
            ->markdown('emails.membership_activated', [
                'user' => $notifiable,
                'membership' => $this->membership,
                'plan' => $this->membership->plan,
                'payment' => $this->membership->payment,
            ]);
    }

    public function toArray(mixed $notifiable): array
    {
        // Este es el payload que se guarda en tabla `notifications.data` (JSON)
        return [
            'title' => 'Tu membresía está activa',
            'body' => "Válida hasta el {$this->membership->expires_at->format('d/m/Y')}.",
            'link' => '/dashboard/membresia',
            'membership_id' => $this->membership->id,
            'icon' => 'membership',
        ];
    }
}

Channels activos ​

ChannelClaseDriver configPayload
databaseDatabaseChannelTabla notifications (Laravel default)Lo que retorna toArray()
mailMailChannelMAIL_MAILER=smtp (Postfix local, 127.0.0.1:25)Lo que retorna toMail()
sms (Fase 2)Custom con Twilioservices.twiliotoSms()
whatsapp (Fase 2)Custom con Twilio WA—toWhatsApp()

Disparar una notification ​

php
$user->notify(new MembershipActivatedNotification($membership));

// Múltiples destinatarios
Notification::send($recipients, new InterviewScheduledNotification($interview));

Behavior por canal ​

  • database: inserta en tabla notifications con UUID primary key. El frontend la lee con GET /me/notifications.
  • mail: ShouldQueue implementado → va al queue worker. Si QUEUE_CONNECTION=sync (dev), se envía inmediato. Si redis (prod), se encola.

Plantillas de correo ​

Viven en resources/views/emails/*.blade.php. Usan el layout Markdown nativo de Laravel.

blade
@component('mail::message')
# Hola, {{ $user->name }}

Tu membresía HUMAE está activa hasta el **{{ $membership->expires_at->format('d/m/Y') }}**.

Ya apareces en nuestro directorio de talento. Un reclutador puede contactarte en cualquier momento.

@component('mail::button', ['url' => config('app.frontend_url').'/dashboard'])
Ver mi perfil
@endcomponent

---

**Detalles del pago**
- Plan: {{ $plan->name }}
- Monto: ${{ number_format($payment->amount, 2) }} MXN
- Fecha: {{ $payment->paid_at->format('d/m/Y H:i') }}
- Referencia: {{ $payment->stripe_payment_intent_id }}

Saludos,<br>
{{ config('app.name') }}
@endcomponent

Preview de plantillas en dev ​

bash
php artisan tinker

>>> $u = User::find(1);
>>> $u->notify(new \App\Notifications\MembershipActivatedNotification($u->memberships->first()));

Revisa MailHog (http://localhost:8025) para ver el correo.

Las 11 notifications del MVP ​

NotificationTriggerCanales
VerifyEmailRegistromail
WelcomeNotificationPost verificaciónmail
ResetPasswordNotificationForgot passwordmail
MembershipActivatedNotificationWebhook Stripe exitosomail + db
AssignmentPresentedNotificationStage → presentedmail + db
InterviewScheduledNotificationRecruiter agendamail + db
InterviewConfirmedNotificationParte confirmamail + db
InterviewRescheduledNotificationReprogramaciónmail + db
InterviewCancelledNotificationCancelaciónmail + db
CandidateHiredNotificationStage → hiredmail + db
AssignmentRejectedNotification (opt-in)Stage → rejectedmail

Catálogo funcional completo: Notificaciones → Eventos.

Tabla notifications ​

Schema estándar Laravel:

notifications
├── id (UUID)
├── type (string)            Nombre completo de la clase
├── notifiable_type          Siempre 'App\Models\User' (polymorphic)
├── notifiable_id            User ID
├── data (JSON)              toArray() output
├── read_at (nullable)
├── created_at
├── updated_at

Indexed por (notifiable_type, notifiable_id) para queries rápidas del frontend.

Endpoints del frontend ​

RutaMétodoUso
/api/v1/me/notificationsGETListar paginado
/api/v1/me/notifications/unread-countGETPara la campana (polling 60s)
/api/v1/me/notifications/{id}/mark-readPOSTMarcar como leída
/api/v1/me/notifications/mark-all-readPOSTTodas
/api/v1/me/notifications/{id}DELETEEliminar

Jobs ​

app/Jobs/ contiene queueable jobs — trabajos pesados que no deben bloquear el request.

ExpireMembershipsJob ​

Único job del MVP. Corre diariamente.

Catálogo completo

Para el inventario consolidado de todo lo que corre autónomamente (jobs programados, cronjobs externos, roadmap de Fase 2), ver Cronjobs y tareas programadas.

php
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Services\MembershipService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

final class ExpireMembershipsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $timeout = 120; // segundos

    public function handle(MembershipService $service): void
    {
        $count = $service->expireStale();
        logger()->info("[ExpireMembershipsJob] expired {$count} memberships");
    }
}

Registrar en el scheduler ​

En routes/console.php (Laravel 11/12) o app/Console/Kernel.php:

php
use Illuminate\Support\Facades\Schedule;

Schedule::job(new ExpireMembershipsJob)
    ->dailyAt('00:15')
    ->name('memberships:expire')
    ->onOneServer();

Ver jobs programados:

bash
php artisan schedule:list

Dispatch manual ​

php
// En un controller o service
ExpireMembershipsJob::dispatch();

// Con delay
ExpireMembershipsJob::dispatch()->delay(now()->addMinutes(5));

// Sync (para tests)
ExpireMembershipsJob::dispatchSync();

Jobs que podrías agregar (Fase 2) ​

  • SendMembershipExpiringWarningJob — correo 15 días antes de expirar
  • GenerateDailyReportsJob — pre-agregar métricas para /admin/reportes
  • CleanupDeletedAccountsJob — borrar data de cuentas soft-deleted > 90 días
  • ReindexDirectorySearchJob — si se agrega Meilisearch/Algolia

Queue ​

Drivers ​

DriverCuándo usarlo
syncSolo dev. Ejecuta el job en el mismo request (bloqueante).
databaseAlternativa simple en prod — guarda en tabla jobs. Bueno para bajo volumen.
redisRecomendado prod. Rápido y soporta blocking pops.
beanstalkd, sqsAlternativas gestionadas.

Config en .env:

env
# Dev
QUEUE_CONNECTION=sync

# Prod
QUEUE_CONNECTION=redis

Worker ​

Comando:

bash
php artisan queue:work redis \
    --queue=default \
    --tries=3 \
    --max-time=3600 \
    --timeout=90

Flags:

  • --queue=default — qué cola procesar (puedes tener colas múltiples para priority).
  • --tries=3 — reintentos antes de pasar a failed_jobs.
  • --max-time=3600 — worker se reinicia cada 1h (previene memory leaks de PHP).
  • --timeout=90 — si un job tarda más, se mata.

Supervisor en prod ​

/etc/supervisor/conf.d/humae-queue.conf:

ini
[program:humae-queue]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/humae_backend/artisan queue:work redis --queue=default --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/supervisor/humae-queue.log
stopwaitsecs=3600

Activar:

bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start humae-queue:*

Queues con prioridad ​

php
// Job de alta prioridad
MyUrgentJob::dispatch()->onQueue('high');

// Job normal
MyNormalJob::dispatch(); // cola default

Worker:

bash
php artisan queue:work redis --queue=high,default,low

Los workers procesan high antes que default antes que low.

Failed jobs ​

Jobs que fallaron $tries veces van a la tabla failed_jobs.

bash
# Ver
php artisan queue:failed

# Reintentar uno
php artisan queue:retry <uuid>

# Reintentar todos
php artisan queue:retry all

# Limpiar tabla
php artisan queue:flush

Cómo manejar un fallo en el job ​

php
public function handle(MembershipService $service): void
{
    // ... lógica
}

public function failed(?\Throwable $exception): void
{
    // Se invoca cuando el job agotó todos los reintentos
    logger()->error('[ExpireMembershipsJob] failed permanently', [
        'exception' => $exception?->getMessage(),
    ]);

    // Notificar al admin
    Notification::route('mail', 'admin@humae.com.mx')
        ->notify(new JobFailedNotification('ExpireMembershipsJob'));
}

Scheduler ​

Cron del servidor ​

Una sola entrada en crontab (como www-data user):

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

Laravel dentro de schedule:run decide qué jobs ejecutar en esa minuto.

Definir schedule en código ​

Laravel 11+ usa routes/console.php o bootstrap/app.php:

php
use Illuminate\Support\Facades\Schedule;

Schedule::job(new ExpireMembershipsJob)
    ->dailyAt('00:15')
    ->name('memberships:expire')
    ->onOneServer();

Schedule::command('telescope:prune --hours=48')
    ->daily()
    ->when(fn () => config('telescope.enabled', false));

Schedule::call(function () {
    // Inline closure, útil para tareas simples
    DB::table('audit_logs')->where('created_at', '<', now()->subMonths(6))->delete();
})->monthly()->name('prune-audit-logs');

Helpers de frecuencia ​

MétodoFrecuencia
->everyMinute()cada minuto
->everyFiveMinutes()cada 5 min
->hourly()cada hora
->daily()diariamente 00:00
->dailyAt('03:00')diariamente a las 3 AM
->twiceDaily(1, 13)dos veces al día
->weeklyOn(1, '8:00')lunes 8 AM
->monthlyOn(15, '3:00')día 15 del mes
->cron('0 * * * 1-5')cron crudo

Protección contra overlaps ​

php
Schedule::job(new ExpireMembershipsJob)
    ->daily()
    ->withoutOverlapping(10) // 10 min lock
    ->onOneServer();          // si hay multi-server, solo uno ejecuta

Timezone ​

php
Schedule::job(new ExpireMembershipsJob)
    ->timezone('America/Mexico_City')
    ->dailyAt('00:15');

Broadcasting (Fase 2) ​

No hay broadcasting en el MVP. Si agregas (para actualización en tiempo real del dashboard):

  • Pusher / Ably / Soketi
  • BROADCAST_CONNECTION=pusher
  • Laravel Echo en frontend

Siguiente ​

Catálogo completo de cronjobs y tareas programadas: Cronjobs → · después, estrategia de tests: Testing →.

Manual de usuario HUMAE · Uso interno