Contexto
offload-project/laravel-waitlist é um pacote Laravel 11/12/13 (PHP 8.3+) para gerenciar uma ou várias listas de espera. Ele fornece:
- Um modelo Eloquent
Waitlist(uma lista de espera nomeada com umslug) e um modeloWaitlistEntry(uma pessoa esperando em uma lista). - Um
WaitlistService(resolvido através da facadeWaitlist) com métodosfor(),create(),add(),invite(),reject(),sendVerification(),verify()e helpers de consulta/contagem. - Fluxo opcional de verificação de e-mail com uma rota publicada
/waitlist/verify/{token}. - Ponte opcional para
offload-project/laravel-invite-only: chamarWaitlist::invite()cria umaInvitationreal (token, expiração, eventos) e persiste a FK emWaitlistEntry::$invitation_id. - Duas notificações:
WaitlistInvited(opt-in viaauto_send_invitation) eVerifyWaitlistEmail. - Eventos de ciclo de vida em
OffloadProject\Waitlist\Events:WaitlistCreated,WaitlistEntryAdded,WaitlistEntryVerified,WaitlistEntryInvited,WaitlistEntryRejected, além deWaitlistEntrySubscribed,WaitlistEntryUnsubscribedeMailingListSyncFailed. - Uma integração de lista de e-mails (facade
MailingList,MailingListManager) que sincroniza entradas para Mailchimp, Kit (ConvertKit) ou Audienceful em um job enfileirado, com driverslogearraye um hookextend()para outros. - Exceções tipadas:
UnverifiedEntryException(convite tentado em uma entrada não verificada enquanto a verificação está ativa) eMailingListException(falhas de driver/credencial/list-id/API).
Aplique este skill ao trabalhar em uma aplicação Laravel que tenha offload-project/laravel-waitlist no composer.json, ou quando o usuário pedir ajuda com Waitlist, WaitlistEntry, as facades Waitlist ou MailingList, ou fluxos de lista de espera neste pacote.
Regras
Uso da Facade
- Use a facade
Waitlist(OffloadProject\Waitlist\Facades\Waitlist) — não instancieWaitlistServicediretamente. A facade é o ponto de entrada suportado. - Para segmentar uma lista de espera específica, encadeie
Waitlist::for($slugOuIdOuModel)->.... Semfor(...), as chamadas operam na lista de espera padrão (criada automaticamente no primeiro uso viagetDefault()). Waitlist::for(...)altera o estado interno de um serviço singleton. Para processos de longa duração (workers de fila, Octane), chamefor(...)para cada operação em vez de confiar que um contexto anterior permanece.
Listas de espera vs. entradas
- Crie listas de espera com
Waitlist::create(string $name, string $slug, ?string $description = null, bool $isActive = true). Oslugé o identificador canônico — é o quefor(...)efind(...)esperam. - Adicione entradas via
Waitlist::for($slug)->add($name, $email, $metadata = []). Não chameWaitlistEntry::create([...])diretamente quando quiser que o fluxo de verificação seja executado —add()dispara automaticamentesendVerification()quandowaitlist.verification.enabledé verdadeiro. - A restrição única em
waitlist_entriesé['waitlist_id', 'email'], não apenasemail. A mesma pessoa pode entrar em várias listas de espera.
Convidando
- Convide via
Waitlist::invite($entryOrId, $options = []). Não chame$entry->markAsInvited()sozinho quando quiser notificações e um registroInvitation—invite()cria aInvitationdolaravel-invite-only, a vincula viainvitation_ide marca a entrada como convidada. $optionssão repassados paraInviteOnly::invite(...). Chaves comuns:'invited_by'(Model ou int — recai paraauth()->user()),'role','metadata','expires_at'. Não duplique chaves já definidas emwaitlist.invitable.metadata_mapper— as$optionsexplícitas vencem viaarray_merge.- Se
waitlist.verification.enabledfor verdadeiro ewaitlist.verification.require_before_invitefor verdadeiro, chamarinvite()em uma entrada não verificada lançaUnverifiedEntryException. Capture-a explicitamente em fluxos voltados ao usuário; não a esconda debaixo de um\Throwablegenérico. - A notificação
WaitlistInvitedé opt-in (waitlist.auto_send_invitationpadrão éfalse). A notificação de convite dolaravel-invite-onlyé enviada independentemente. Ativeauto_send_invitationapenas quando quiser um segundo e-mail com a marca da lista de espera.
Verificação
- Dispare a verificação através de
Waitlist::sendVerification($entry)— ele gera um token novo (generateVerificationToken()sobrescreve qualquer existente) e enviaVerifyWaitlistEmail. Não crie sua própria geração de token; use a do pacote para que a rota de verificação continue funcionando. - Confirme tokens via
Waitlist::verify($token). Retorna oWaitlistEntryem caso de sucesso,nullpara token desconhecido. Após a verificação, o token é limpo (uso único). - Personalize a notificação de verificação via configuração
waitlist.verification.notification; ela recebe oWaitlistEntryem seu construtor. Leia o token de$entry->verification_tokene chameroute('waitlist.verify', ['token' => $entry->verification_token]). - A rota de verificação do pacote é montada sob
waitlist.routes.prefix(padrãowaitlist) comwaitlist.routes.middleware(padrão['web']). Para usar seu próprio controller, definawaitlist.routes.enabled => falsee chameWaitlist::verify($token)de sua ação.
Status e verificações
- Os status das entradas são os literais de string
pending,invited,rejected. Prefira$entry->isPending(),isInvited(),isRejected()em vez de comparações diretas de string. - O estado de verificação reside em
verified_ateverification_token. Verifique viaisVerified()eisPendingVerification(); não compare timestamps brutos. - Para bloqueio de UI "aguarde até verificar", use
isPendingVerification()(token definido, ainda não verificado).isVerified()sozinho retornafalsepara entradas que nunca iniciaram verificação — esses dois estados são diferentes.
Configuração de entidade convidável (invitable)
- Quando o aplicativo host estiver convidando pessoas para uma entidade específica (Equipe, Organização, Projeto), configure uma vez em
config/waitlist.phpsobinvitable:invitable.model— string da classe; o pacote chama::first()nela. Use apenas para aplicativos single-tenant.invitable.resolver— closurefn(WaitlistEntry $entry) => Model|nullpara o caso multi-tenant. Extraia o ID do tenant de$entry->metadataou outra coluna.invitable.metadata_mapper— closurefn(WaitlistEntry $entry) => arraypara traduzir metadados da entrada em metadados de convite (ex.:['role' => 'beta-tester']).
- Não codifique um invitable por local de chamada. Se fluxos diferentes precisarem de invitables diferentes, use o closure
resolvercom um discriminador emmetadata.
Eventos
- Conecte-se ao ciclo de vida com os eventos do próprio pacote em vez de envolver a facade ou consultar a tabela. Eles vivem em
OffloadProject\Waitlist\Eventse cada um carrega o modelo como uma propriedade somente leitura ($event->entry,$event->waitlist). - Os eventos de ciclo de vida disparam dos modelos (
WaitlistEntryAddedvia$dispatchesEventsna criação; os demais demarkAsInvited()/markAsRejected()/markAsVerified()), então listeners ainda executam para código que ignora a facade. - Use
Event::fake([SpecificEvent::class])em testes, não umEvent::fake()genérico — uma simulação geral também engoleWaitlistEntryAdded, o que impede que o listener da lista de e-mails seja executado.
Sincronização de lista de e-mails
- Ative-a com
waitlist.mailing_list.enablede escolha um driver (mailchimp,kit,audienceful,log,array). Conecte uma lista de espera a uma lista comWaitlist::for($slug)->connectMailingList($listId, $driver = null)— o list id é um audience id do Mailchimp, um form id do Kit (ou tag id quandolist_typeétag), ou um publication id do Audienceful (ou nome da tag quandolist_typeétag). Listas de espera sem lista própria usam olist_idconfigurado no driver. - Não construa seu próprio listener de "inscrever ao se cadastrar". A inscrição é automática e segue a configuração de verificação: com
waitlist.verification.enableddesligado, a entrada sincroniza emadd(), com ele ligado, a entrada sincroniza apósWaitlist::verify(). Essa ordem é intencional — um endereço não confirmado nunca deve chegar à newsletter. - Para qualquer coisa além de inscrever — marcar tag no convite, remover na rejeição, mover entre listas — ouça os eventos de ciclo de vida e chame
MailingList::tagEntry($entry, [...])ouWaitlist::unsubscribeFromMailingList($entry). Não adicione esses efeitos colaterais dentro dos controllers do aplicativo host. - A sincronização é executada através de jobs enfileirados (
SyncEntryToMailingList,UnsubscribeEntryFromMailingList). Mantenhamailing_list.queue.enabledligado em produção para que cadastros nunca bloqueiem na API do provedor. Preencha linhas existentes comphp artisan waitlist:sync-mailing-list [slug] [--all] [--force]ouWaitlist::for($slug)->syncMailingList(). - Adicione um serviço que o pacote não inclui implementando
OffloadProject\Waitlist\Contracts\MailingListDrivere registrando-o comMailingList::extend('name', fn (array $config) => new YourDriver(...))de um service provider. Não fork os drivers fornecidos. - Em testes use
MailingList::fake(), que substitui peloArrayDriverem memória, executa sincronizações inline e expõehasSubscriber(),subscribers()etagsFor(). Recorra aHttp::fake()apenas quando afirmar a requisição exata que um driver real envia.
Não fazer
- Não execute mudanças de ciclo de vida via chamadas diretas
update()($entry->update(['status' => 'invited'])). UsemarkAsInvited()/markAsRejected()/markAsVerified()para que casts, efeitos colaterais (timestamps, limpeza de token) e eventos permaneçam consistentes. Melhor ainda: dirija tudo através da facade. - Não edite as migrations publicadas para adicionar colunas — escreva uma migration de acompanhamento no aplicativo host. O pacote pode adicionar colunas em versões futuras e assumirá o esquema publicado.
- Não crie subclasses de
WaitlistouWaitlistEntry; ambos sãofinal. Adicione comportamento no lado do aplicativo host via listeners nos eventos do pacote, ou estendendo o serviço através de uma binding personalizada no container da sua aplicação.
Exemplos
Lista de espera única (sem configuração)
use OffloadProject\Waitlist\Facades\Waitlist;
$entry = Waitlist::add('João Silva', '[email protected]', ['source' => 'landing-page']);
Waitlist::invite($entry, [
'invited_by' => auth()->user(),
'expires_at' => now()->addDays(14),
]);
Múltiplas listas de espera
Waitlist::create('Programa Beta', 'beta');
Waitlist::create('Acesso VIP', 'vip');
Waitlist::for('beta')->add('Maria Souza', '[email protected]');
Waitlist::for('vip')->add('Carlos Pereira', '[email protected]');
$pendentesBeta = Waitlist::for('beta')->getPending();
$contagemVip = Waitlist::for('vip')->count();
Fluxo de verificação
// 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('João Silva', '[email protected]');
// E-mail de verificação enviado automaticamente.
try {
Waitlist::invite($entry);
} catch (UnverifiedEntryException) {
return back()->withErrors(['email' => 'Por favor, verifique seu e-mail primeiro.']);
}
Configurando um modelo convidável (convites de equipe)
// 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',
],
],
Depois:
Waitlist::for('beta')->add('Maria', '[email protected]', [
'team_id' => $team->id,
'role' => 'admin',
]);
Quando você chamar Waitlist::invite($entry) posteriormente, o convite laravel-invite-only resultante será escopo para aquela equipe com role=admin.
Notificação de verificação 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('Confirme seu lugar na lista de espera')
->greeting("Olá {$this->entry->name}!")
->action('Verificar E-mail', $url);
}
}
// config/waitlist.php
'verification' => [
'enabled' => true,
'notification' => \App\Notifications\CustomVerifyWaitlistEmail::class,
],
Sincronizando cadastros para Mailchimp
// config/waitlist.php
'mailing_list' => [
'enabled' => true,
'default' => 'mailchimp',
'drivers' => [
'mailchimp' => [
'key' => env('MAILCHIMP_API_KEY'), // o sufixo carrega o data centre, ex.: -us14
'list_id' => env('MAILCHIMP_LIST_ID'),
],
],
],
// Um público por lista de espera (opcional — caso contrário, o list_id da configuração é usado).
Waitlist::for('beta')->connectMailingList('a1b2c3d4e5');
// Inscrito automaticamente, na adição ou na verificação dependendo da configuração.
Waitlist::for('beta')->add('Maria Souza', '[email protected]');
Reagindo ao 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));
Testando um fluxo de lista de e-mails
use OffloadProject\Waitlist\Facades\MailingList;
use OffloadProject\Waitlist\Facades\Waitlist;
MailingList::fake();
$entry = Waitlist::for('beta')->add('Teste', '[email protected]');
// Afirme que o assinante foi inscrito na lista.
MailingList::assertSubscriberAdded('[email protected]');
// Ou inspecione a lista.
MailingList::hasSubscriber('[email protected]'); // true
// Testando convites rejeitados
MailingList::fake();
$entry = Waitlist::for('beta')->add('Teste', '[email protected]');
Waitlist::reject($entry);
// O assinante foi removido da lista de e-mails.
MailingList::assertSubscriberRemoved('[email protected]');
Para testar eventos específicos, use:
use Illuminate\Support\Facades\Event;
use OffloadProject\Waitlist\Events\WaitlistEntryAdded;
Event::fake([WaitlistEntryAdded::class]);
// ... execute sua ação
Event::assertDispatched(WaitlistEntryAdded::class);