Apariencia
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
| Channel | Clase | Driver config | Payload |
|---|---|---|---|
database | DatabaseChannel | Tabla notifications (Laravel default) | Lo que retorna toArray() |
mail | MailChannel | MAIL_MAILER=smtp (Postfix local, 127.0.0.1:25) | Lo que retorna toMail() |
sms (Fase 2) | Custom con Twilio | services.twilio | toSms() |
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
notificationscon UUID primary key. El frontend la lee conGET /me/notifications. - mail:
ShouldQueueimplementado → va al queue worker. SiQUEUE_CONNECTION=sync(dev), se envía inmediato. Siredis(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') }}
@endcomponentPreview 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
| Notification | Trigger | Canales |
|---|---|---|
VerifyEmail | Registro | |
WelcomeNotification | Post verificación | |
ResetPasswordNotification | Forgot password | |
MembershipActivatedNotification | Webhook Stripe exitoso | mail + db |
AssignmentPresentedNotification | Stage → presented | mail + db |
InterviewScheduledNotification | Recruiter agenda | mail + db |
InterviewConfirmedNotification | Parte confirma | mail + db |
InterviewRescheduledNotification | Reprogramación | mail + db |
InterviewCancelledNotification | Cancelación | mail + db |
CandidateHiredNotification | Stage → hired | mail + db |
AssignmentRejectedNotification (opt-in) | Stage → rejected |
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_atIndexed por (notifiable_type, notifiable_id) para queries rápidas del frontend.
Endpoints del frontend
| Ruta | Método | Uso |
|---|---|---|
/api/v1/me/notifications | GET | Listar paginado |
/api/v1/me/notifications/unread-count | GET | Para la campana (polling 60s) |
/api/v1/me/notifications/{id}/mark-read | POST | Marcar como leída |
/api/v1/me/notifications/mark-all-read | POST | Todas |
/api/v1/me/notifications/{id} | DELETE | Eliminar |
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:listDispatch 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 expirarGenerateDailyReportsJob— pre-agregar métricas para/admin/reportesCleanupDeletedAccountsJob— borrar data de cuentas soft-deleted > 90 díasReindexDirectorySearchJob— si se agrega Meilisearch/Algolia
Queue
Drivers
| Driver | Cuándo usarlo |
|---|---|
sync | Solo dev. Ejecuta el job en el mismo request (bloqueante). |
database | Alternativa simple en prod — guarda en tabla jobs. Bueno para bajo volumen. |
redis | Recomendado prod. Rápido y soporta blocking pops. |
beanstalkd, sqs | Alternativas gestionadas. |
Config en .env:
env
# Dev
QUEUE_CONNECTION=sync
# Prod
QUEUE_CONNECTION=redisWorker
Comando:
bash
php artisan queue:work redis \
--queue=default \
--tries=3 \
--max-time=3600 \
--timeout=90Flags:
--queue=default— qué cola procesar (puedes tener colas múltiples para priority).--tries=3— reintentos antes de pasar afailed_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=3600Activar:
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 defaultWorker:
bash
php artisan queue:work redis --queue=high,default,lowLos 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:flushCó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>&1Laravel 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étodo | Frecuencia |
|---|---|
->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 ejecutaTimezone
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 →.

