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 WAtoWhatsApp()

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