Contexto
offload-project/laravel-waitlist es un paquete para Laravel 11/12/13 (PHP 8.3+) que permite gestionar una o varias listas de espera. Incluye:
- Un modelo Eloquent
Waitlist(una lista de espera con nombre y unslug) y un modeloWaitlistEntry(una persona esperando en una lista). - Un
WaitlistService(resuelto a través de la fachadaWaitlist) con los métodosfor(),create(),add(),invite(),reject(),sendVerification(),verify(), y ayudantes para consultas y conteos. - Un flujo opcional de verificación de correo electrónico con una ruta publicada
/waitlist/verify/{token}. - Un puente opcional hacia
offload-project/laravel-invite-only: al llamar aWaitlist::invite()se crea unaInvitationreal (token, expiración, eventos) y se persiste la clave foránea enWaitlistEntry::$invitation_id. - Dos notificaciones:
WaitlistInvited(opt-in medianteauto_send_invitation) yVerifyWaitlistEmail. - Eventos del ciclo de vida en
OffloadProject\Waitlist\Events:WaitlistCreated,WaitlistEntryAdded,WaitlistEntryVerified,WaitlistEntryInvited,WaitlistEntryRejected, además deWaitlistEntrySubscribed,WaitlistEntryUnsubscribedyMailingListSyncFailed. - Una integración con listas de correo (fachada
MailingList,MailingListManager) que sincroniza las entradas con Mailchimp, Kit (ConvertKit) o Audienceful en un trabajo encolado, con controladoreslogyarrayy un hookextend()para otros. - Excepciones tipadas:
UnverifiedEntryException(cuando se intenta invitar a una entrada no verificada y la compuerta de verificación está activa) yMailingListException(fallos de controlador/credenciales/id-lista/API).
Aplica este skill cuando trabajes en una aplicación Laravel que tenga offload-project/laravel-waitlist en el composer.json, o cuando el usuario pida ayuda con Waitlist, WaitlistEntry, las fachadas Waitlist o MailingList, o flujos de listas de espera en este paquete.
Reglas
Uso de fachadas
- Usa la fachada
Waitlist(OffloadProject\Waitlist\Facades\Waitlist) — no instanciesWaitlistServicedirectamente. La fachada es el punto de entrada soportado. - Para apuntar a una lista de espera específica, encadena
Waitlist::for($slugOrIdOrModel)->.... Sinfor(...), las llamadas operan sobre la lista de espera por defecto (creada automáticamente en el primer uso mediantegetDefault()). Waitlist::for(...)muta el estado interno de un servicio singleton. Para procesos de larga duración (trabajadores de cola, Octane), llama afor(...)para cada operación en lugar de confiar en que el contexto anterior persista.
Listas de espera vs. entradas
- Crea listas de espera con
Waitlist::create(string $name, string $slug, ?string $description = null, bool $isActive = true). Elsluges el identificador canónico — eso es lo que esperanfor(...)yfind(...). - Añade entradas mediante
Waitlist::for($slug)->add($name, $email, $metadata = []). No llames aWaitlistEntry::create([...])directamente cuando quieras que se ejecute el flujo de verificación —add()activa automáticamentesendVerification()cuandowaitlist.verification.enabledestrue. - La restricción única en
waitlist_entrieses['waitlist_id', 'email'], no soloemail. Una misma persona puede unirse a varias listas de espera.
Invitaciones
- Invita mediante
Waitlist::invite($entryOrId, $options = []). No llames a$entry->markAsInvited()por sí solo cuando quieras notificaciones y un registro deInvitation—invite()crea laInvitationdelaravel-invite-only, la vincula medianteinvitation_idy marca la entrada como invitada. $optionsse pasa aInviteOnly::invite(...). Claves comunes:'invited_by'(Model o int — por defectoauth()->user()),'role','metadata','expires_at'. No dupliques claves que ya hayas establecido enwaitlist.invitable.metadata_mapper— las$optionsexplícitas ganan mediantearray_merge.- Si
waitlist.verification.enabledestrueywaitlist.verification.require_before_inviteestrue, llamar ainvite()en una entrada no verificada lanzaUnverifiedEntryException. Cógela explícitamente en flujos orientados al usuario; no la ocultes bajo un\Throwablegenérico. - La notificación
WaitlistInvitedes opt-in (waitlist.auto_send_invitationpor defecto esfalse). La notificación de invitación delaravel-invite-onlyse envía siempre. Activaauto_send_invitationsolo cuando quieras un segundo correo electrónico con la marca de la lista de espera además del otro.
Verificación
- Activa la verificación mediante
Waitlist::sendVerification($entry)— genera un token nuevo (generateVerificationToken()sobrescribe cualquier token existente) y envíaVerifyWaitlistEmail. No implementes tu propia generación de tokens; usa la del paquete para que la ruta de verificación siga funcionando. - Confirma tokens mediante
Waitlist::verify($token). Devuelve laWaitlistEntryen caso de éxito,nullsi el token es desconocido. Después de la verificación, el token se borra (un solo uso). - Personaliza la notificación de verificación mediante la configuración
waitlist.verification.notification; recibe laWaitlistEntryen su constructor. Lee el token de$entry->verification_tokeny llama aroute('waitlist.verify', ['token' => $entry->verification_token]). - La ruta de verificación del paquete se monta bajo
waitlist.routes.prefix(por defectowaitlist) conwaitlist.routes.middleware(por defecto['web']). Para usar tu propio controlador, establecewaitlist.routes.enabled => falsey llama aWaitlist::verify($token)desde tu acción.
Estado y comprobaciones
- Los estados de entrada son los literales de cadena
pending,invited,rejected. Prefiere$entry->isPending(),isInvited(),isRejected()sobre comparaciones directas de cadenas. - El estado de verificación reside en
verified_atyverification_token. Comprueba medianteisVerified()yisPendingVerification(); no compares marcas de tiempo directamente. - Para la compuerta de UI "bloquear hasta verificar", usa
isPendingVerification()(token establecido, aún no verificado).isVerified()solo devuelvefalsepara entradas que nunca iniciaron la verificación — esos dos estados son diferentes.
Cableado de elementos invitables (invitable)
- Cuando la aplicación anfitriona invita a personas a una entidad específica (Equipo, Organización, Proyecto), configúralo una vez en
config/waitlist.phpbajoinvitable:invitable.model— cadena de clase; el paquete llama a::first(). Úsalo solo para aplicaciones de un solo inquilino.invitable.resolver— clausurafn(WaitlistEntry $entry) => Model|nullpara el caso multiinquilino. Extrae el ID del inquilino de$entry->metadatau otra columna.invitable.metadata_mapper— clausurafn(WaitlistEntry $entry) => arraypara traducir los metadatos de la entrada en metadatos de invitación (por ejemplo,['role' => 'beta-tester']).
- No codifiques un invitable por cada punto de llamada. Si diferentes flujos necesitan diferentes invitables, usa la clausura
resolvercon un discriminador enmetadata.
Eventos
- Conéctate al ciclo de vida usando los eventos del propio paquete en lugar de envolver la fachada o sondear la tabla. Viven en
OffloadProject\Waitlist\Eventsy cada uno lleva el modelo como una propiedad de solo lectura ($event->entry,$event->waitlist). - Los eventos del ciclo de vida se disparan desde los modelos (
WaitlistEntryAddedmediante$dispatchesEventsal crear; el resto desdemarkAsInvited()/markAsRejected()/markAsVerified()), por lo que los oyentes siguen ejecutándose incluso para código que evita la fachada. - Usa
Event::fake([SpecificEvent::class])en las pruebas, no unEvent::fake()simple — un fake genérico también se tragaWaitlistEntryAdded, lo que impide que el oyente de la lista de correo se ejecute.
Sincronización de listas de correo
- Actívala con
waitlist.mailing_list.enabledy elige un controlador (mailchimp,kit,audienceful,log,array). Conecta una lista de espera a una lista conWaitlist::for($slug)->connectMailingList($listId, $driver = null)— el id de la lista es un id de audiencia de Mailchimp, un id de formulario de Kit (o id de etiqueta cuandolist_typeestag), o un id de publicación de Audienceful (o nombre de etiqueta cuandolist_typeestag). Las listas de espera sin lista propia recurren allist_idconfigurado en el controlador. - No construyas tu propio oyente de "suscribir al registrarse". La suscripción es automática y sigue la configuración de verificación: con
waitlist.verification.enableddesactivado, la entrada se sincroniza enadd(); con ella activada, la entrada se sincroniza después deWaitlist::verify(). Ese orden es deliberado — una dirección no confirmada nunca debe llegar a la newsletter. - Para cualquier cosa más allá de la suscripción — etiquetar al invitar, eliminar al rechazar, mover entre listas — escucha los eventos del ciclo de vida y llama a
MailingList::tagEntry($entry, [...])oWaitlist::unsubscribeFromMailingList($entry). No añadas esos efectos secundarios dentro de los controladores de la aplicación anfitriona. - La sincronización se ejecuta a través de trabajos encolados (
SyncEntryToMailingList,UnsubscribeEntryFromMailingList). Manténmailing_list.queue.enabledactivo en producción para que los registros nunca bloqueen la API del proveedor. Rellena las filas existentes conphp artisan waitlist:sync-mailing-list [slug] [--all] [--force]oWaitlist::for($slug)->syncMailingList(). - Añade un servicio que el paquete no incluya implementando
OffloadProject\Waitlist\Contracts\MailingListDrivery registrándolo conMailingList::extend('name', fn (array $config) => new YourDriver(...))desde un proveedor de servicios. No bifurques los controladores incluidos. - En las pruebas, usa
MailingList::fake(), que intercambia elArrayDriveren memoria, ejecuta las sincronizaciones en línea y exponehasSubscriber(),subscribers()ytagsFor(). Recurre aHttp::fake()solo cuando quieras afirmar la solicitud exacta que envía un controlador real.
Prohibiciones
- No realices cambios en el ciclo de vida mediante llamadas directas
update()($entry->update(['status' => 'invited'])). UsamarkAsInvited()/markAsRejected()/markAsVerified()para que los casts, los efectos secundarios (marcas de tiempo, borrado de tokens) y los eventos se mantengan consistentes. Mejor aún: impulsa todo a través de la fachada. - No edites las migraciones publicadas para añadir columnas — escribe una migración de seguimiento en la aplicación anfitriona. El paquete puede añadir columnas en versiones futuras y asumirá el esquema publicado.
- No subclases
WaitlistniWaitlistEntry; ambas sonfinal. Añade comportamiento en el lado de la aplicación anfitriona mediante oyentes en los eventos del paquete, o extendiendo el servicio mediante un enlace personalizado en el contenedor de tu aplicación.
Ejemplos
Lista de espera única (sin configuración)
use OffloadProject\Waitlist\Facades\Waitlist;
$entry = Waitlist::add('John Doe', '[email protected]', ['source' => 'landing-page']);
Waitlist::invite($entry, [
'invited_by' => auth()->user(),
'expires_at' => now()->addDays(14),
]);
Múltiples listas de espera
Waitlist::create('Programa Beta', 'beta');
Waitlist::create('Acceso VIP', 'vip');
Waitlist::for('beta')->add('Jane Smith', '[email protected]');
Waitlist::for('vip')->add('Bob Wilson', '[email protected]');
$pendientesBeta = Waitlist::for('beta')->getPending();
$conteoVip = Waitlist::for('vip')->count();
Flujo de verificación
// config/waitlist.php
'verification' => [
'enabled' => true,
'require_before_invite' => true,
'notification' => \OffloadProject\Waitlist\Notifications\VerifyWaitlistEmail::class,
],
use OffloadProject\Waitlist\Exceptions\UnverifiedEntryException;
use OffloadProject\Waitlist\Facades\Waitlist;
$entry = Waitlist::add('John Doe', '[email protected]');
// Correo de verificación enviado automáticamente.
try {
Waitlist::invite($entry);
} catch (UnverifiedEntryException) {
return back()->withErrors(['email' => 'Por favor, verifica tu correo electrónico primero.']);
}
Cableado de un modelo invitable (invitaciones a equipo)
// config/waitlist.php
'invitable' => [
'model' => null,
'resolver' => fn (\OffloadProject\Waitlist\Models\WaitlistEntry $entry) =>
\App\Models\Team::find($entry->metadata['team_id'] ?? null),
'metadata_mapper' => fn (\OffloadProject\Waitlist\Models\WaitlistEntry $entry) => [
'role' => $entry->metadata['role'] ?? 'member',
],
],
Luego:
Waitlist::for('beta')->add('Jane', '[email protected]', [
'team_id' => $team->id,
'role' => 'admin',
]);
Cuando luego llames a Waitlist::invite($entry), la invitación resultante de laravel-invite-only estará limitada a ese equipo con role=admin.
Notificación de verificación personalizada
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use OffloadProject\Waitlist\Models\WaitlistEntry;
class CustomVerifyWaitlistEmail extends Notification
{
public function __construct(public WaitlistEntry $entry) {}
public function via($notifiable): array
{
return ['mail'];
}
public function toMail($notifiable): MailMessage
{
$url = route('waitlist.verify', ['token' => $this->entry->verification_token]);
return (new MailMessage)
->subject('Confirma tu lugar en la lista de espera')
->greeting("¡Hola {$this->entry->name}!")
->action('Verificar correo electrónico', $url);
}
}
// config/waitlist.php
'verification' => [
'enabled' => true,
'notification' => \App\Notifications\CustomVerifyWaitlistEmail::class,
],
Sincronización de registros con Mailchimp
// config/waitlist.php
'mailing_list' => [
'enabled' => true,
'default' => 'mailchimp',
'drivers' => [
'mailchimp' => [
'key' => env('MAILCHIMP_API_KEY'), // el sufijo lleva el centro de datos, p.ej. -us14
'list_id' => env('MAILCHIMP_LIST_ID'),
],
],
],
// Una audiencia por lista de espera (opcional — de lo contrario se usa el list_id de la configuración).
Waitlist::for('beta')->connectMailingList('a1b2c3d4e5');
// Suscrito automáticamente, al añadir o al verificar según la configuración.
Waitlist::for('beta')->add('Jane Smith', '[email protected]');
Reaccionando al ciclo de vida
use Illuminate\Support\Facades\Event;
use OffloadProject\Waitlist\Events\WaitlistEntryInvited;
use OffloadProject\Waitlist\Events\WaitlistEntryRejected;
use OffloadProject\Waitlist\Facades\MailingList;
use OffloadProject\Waitlist\Facades\Waitlist;
Event::listen(fn (WaitlistEntryInvited $event) => MailingList::tagEntry($event->entry, ['invited']));
Event::listen(fn (WaitlistEntryRejected $event) => Waitlist::unsubscribeFromMailingList($event->entry));
Probando un flujo de lista de correo
use OffloadProject\Waitlist\Facades\MailingList;
use OffloadProject\Waitlist\Facades\Waitlist;
MailingList::fake();
Waitlist::for('beta')->add('Test', '[email protected]');
// Afirmar
MailingList::assertSubscribed('[email protected]');
MailingList::assertHasSubscriber('[email protected]');