上下文
offload-project/laravel-waitlist 是一个 Laravel 11/12/13 包(PHP 8.3+),用于管理一个或多个等待列表。它提供了:
- 一个
WaitlistEloquent 模型(带有slug的命名等待列表)和一个WaitlistEntry模型(等待列表中的人员)。 - 一个
WaitlistService(通过Waitlist门面解析),提供for()、create()、add()、invite()、reject()、sendVerification()、verify()以及查询/计数辅助方法。 - 可选的邮箱验证流程,带有已发布的
/waitlist/verify/{token}路由。 - 可选桥接到
offload-project/laravel-invite-only:调用Waitlist::invite()会创建一个真实的Invitation(包含令牌、过期时间、事件),并在WaitlistEntry::$invitation_id上持久化外键。 - 两个通知:
WaitlistInvited(通过auto_send_invitation选择加入)和VerifyWaitlistEmail。 - 在
OffloadProject\Waitlist\Events中的生命周期事件:WaitlistCreated、WaitlistEntryAdded、WaitlistEntryVerified、WaitlistEntryInvited、WaitlistEntryRejected,以及WaitlistEntrySubscribed、WaitlistEntryUnsubscribed和MailingListSyncFailed。 - 一个邮件列表集成(
MailingList门面,MailingListManager),通过队列任务将条目同步到 Mailchimp、Kit (ConvertKit) 或 Audienceful,并提供log和array驱动以及extend()钩子供其他驱动使用。 - 类型化异常:
UnverifiedEntryException(在验证门控开启时尝试邀请未验证条目时抛出)和MailingListException(驱动/凭据/列表ID/API 失败时抛出)。
当在含有 offload-project/laravel-waitlist 的 composer.json 的 Laravel 应用中工作时,或用户请求协助处理 Waitlist、WaitlistEntry、Waitlist 或 MailingList 门面或此包中的等待列表流程时,应用此技能。
规则
门面使用
- 使用
Waitlist门面(OffloadProject\Waitlist\Facades\Waitlist)——不要直接实例化WaitlistService。门面是支持的入口点。 - 要定位特定等待列表,链式调用
Waitlist::for($slugOrIdOrModel)->...。如果没有for(...),调用将操作默认等待列表(首次使用时会通过getDefault()自动创建)。 Waitlist::for(...)会修改单例服务的内部状态。对于长时间运行的进程(队列工作者、Octane),每次操作都调用for(...),而不是依赖之前上下文调用的持久性。
等待列表与条目
- 使用
Waitlist::create(string $name, string $slug, ?string $description = null, bool $isActive = true)创建等待列表。slug是规范标识符——for(...)和find(...)期望的就是它。 - 通过
Waitlist::for($slug)->add($name, $email, $metadata = [])添加条目。当希望验证流程运行时,不要直接调用WaitlistEntry::create([...])——当waitlist.verification.enabled为 true 时,add()会自动触发sendVerification()。 waitlist_entries上的唯一约束是['waitlist_id', 'email'],而不仅仅是email。同一个人可以加入多个等待列表。
邀请
- 通过
Waitlist::invite($entryOrId, $options = [])邀请。不要单独调用$entry->markAsInvited(),当希望发送通知和创建Invitation记录时——invite()会创建laravel-invite-only的Invitation,通过invitation_id链接它,并将条目标记为已邀请。 $options会传递给InviteOnly::invite(...)。常见键:'invited_by'(Model 或 int——默认为auth()->user())、'role'、'metadata'、'expires_at'。不要重复在waitlist.invitable.metadata_mapper中已设置的键——显式的$options通过array_merge胜出。- 如果
waitlist.verification.enabled为 true 且waitlist.verification.require_before_invite为 true,则对未验证条目调用invite()会抛出UnverifiedEntryException。在用户面向的流程中显式捕获它;不要将其埋藏在泛型\Throwable下。 WaitlistInvited通知是选择加入的(waitlist.auto_send_invitation默认为false)。来自laravel-invite-only的邀请通知无论如何都会发送。仅当希望在之上发送第二个等待列表品牌邮件时,才启用auto_send_invitation。
验证
- 通过
Waitlist::sendVerification($entry)触发验证——它会生成一个新鲜令牌(generateVerificationToken()会覆盖任何现有令牌)并发送VerifyWaitlistEmail。不要自行生成令牌;使用包的令牌,以便验证路由保持工作。 - 通过
Waitlist::verify($token)确认令牌。成功时返回WaitlistEntry,未知令牌时返回null。验证后令牌会被清除(一次性使用)。 - 通过
waitlist.verification.notification配置自定义验证通知;它在其构造函数中接收WaitlistEntry。从$entry->verification_token读取令牌并调用route('waitlist.verify', ['token' => $entry->verification_token])。 - 包的验证路由挂载在
waitlist.routes.prefix(默认为waitlist)下,使用waitlist.routes.middleware(默认为['web'])。要使用自己的控制器,设置waitlist.routes.enabled => false并从你的动作中调用Waitlist::verify($token)。
状态与检查
- 条目状态是字符串字面量
pending、invited、rejected。优先使用$entry->isPending()、isInvited()、isRejected()而不是原始字符串比较。 - 验证状态位于
verified_at和verification_token上。通过isVerified()和isPendingVerification()检查;不要比较原始时间戳。 - 对于“阻止直到验证”的 UI 门控,使用
isPendingVerification()(令牌已设置,但尚未验证)。isVerified()单独对从未开始验证的条目返回false——这两种状态是不同的。
可邀请模型配置
- 当宿主应用邀请人们加入特定实体(团队、组织、项目)时,在
config/waitlist.php中的invitable下一次配置:invitable.model——类字符串;包在其上调用::first()。仅对单租户应用使用此方法。invitable.resolver——闭包fn(WaitlistEntry $entry) => Model|null,用于多租户情况。从$entry->metadata或另一列获取租户 ID。invitable.metadata_mapper——闭包fn(WaitlistEntry $entry) => array,用于将条目元数据转换为邀请元数据(例如['role' => 'beta-tester'])。
- 不要在每次调用时硬编码可邀请模型。如果不同流程需要不同的可邀请模型,请使用带元数据鉴别器的
resolver闭包。
事件
- 通过包自身的事件钩入生命周期,而不是包装门面或轮询表。它们位于
OffloadProject\Waitlist\Events中,每个事件将模型作为只读属性携带($event->entry、$event->waitlist)。 - 生命周期事件从模型触发(创建时通过
$dispatchesEvents的WaitlistEntryAdded;其余的来自markAsInvited()/markAsRejected()/markAsVerified()),因此即使代码绕过门面,监听器仍然运行。 - 在测试中使用
Event::fake([SpecificEvent::class]),而不是裸Event::fake()——全假也会吞掉WaitlistEntryAdded,从而阻止邮件列表监听器运行。
邮件列表同步
- 使用
waitlist.mailing_list.enabled开启它,并选择驱动(mailchimp、kit、audienceful、log、array)。使用Waitlist::for($slug)->connectMailingList($listId, $driver = null)将等待列表连接到列表——列表 ID 是 Mailchimp 受众 ID、Kit 表单 ID(当list_type为tag时是标签 ID)或 Audienceful 出版物 ID(当list_type为tag时是标签名称)。没有自己列表的等待列表会回退到驱动配置的list_id。 - 不要构建自己的“注册时订阅”监听器。订阅是自动的,并遵循验证设置:当
waitlist.verification.enabled关闭时,条目在add()时同步;当它开启时,条目在Waitlist::verify()后同步。这种顺序是有意的——未确认的地址绝不能到达新闻通讯。 - 对于超出订阅的操作——邀请时打标签、拒绝时移除、列表间移动——监听生命周期事件并调用
MailingList::tagEntry($entry, [...])或Waitlist::unsubscribeFromMailingList($entry)。不要将这些副作用添加到宿主应用的控制器中。 - 同步通过队列任务运行(
SyncEntryToMailingList、UnsubscribeEntryFromMailingList)。在生产环境中保持mailing_list.queue.enabled开启,以便注册永远不会阻塞在提供商 API 上。使用php artisan waitlist:sync-mailing-list [slug] [--all] [--force]或Waitlist::for($slug)->syncMailingList()回填现有行。 - 添加包未提供的服务,通过实现
OffloadProject\Waitlist\Contracts\MailingListDriver并从服务提供者中使用MailingList::extend('name', fn (array $config) => new YourDriver(...))注册它。不要派生已提供的驱动。 - 在测试中使用
MailingList::fake(),它会切换到内存中的ArrayDriver,内联运行同步,并暴露hasSubscriber()、subscribers()和tagsFor()。仅当需要断言真实驱动发送的确切请求时才使用Http::fake()。
禁止操作
- 不要通过直接
update()调用($entry->update(['status' => 'invited']))运行生命周期更改。使用markAsInvited()/markAsRejected()/markAsVerified(),以便类型转换、副作用(时间戳、令牌清除)和事件保持一致。更好的做法:通过门面驱动所有操作。 - 不要编辑已发布的迁移来添加列——在宿主应用中编写后续迁移。包可能会在将来的版本中添加列,并假定使用发布的模式。
- 不要子类化
Waitlist或WaitlistEntry;两者都是final。通过包事件的监听器,或通过应用容器中的自定义绑定扩展服务,在宿主应用一侧添加行为。
示例
单等待列表(无配置)
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),
]);
多等待列表
Waitlist::create('Beta Program', 'beta');
Waitlist::create('VIP Access', 'vip');
Waitlist::for('beta')->add('Jane Smith', '[email protected]');
Waitlist::for('vip')->add('Bob Wilson', '[email protected]');
$pendingBeta = Waitlist::for('beta')->getPending();
$vipCount = Waitlist::for('vip')->count();
验证流程
// 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]');
// 验证邮件自动发送。
try {
Waitlist::invite($entry);
} catch (UnverifiedEntryException) {
return back()->withErrors(['email' => '请先验证您的邮箱。']);
}
配置可邀请模型(团队邀请)
// 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',
],
],
然后:
Waitlist::for('beta')->add('Jane', '[email protected]', [
'team_id' => $team->id,
'role' => 'admin',
]);
当你稍后调用 Waitlist::invite($entry) 时,生成的 laravel-invite-only 邀请将限定于该团队,并带有 role=admin。
自定义验证通知
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('确认您在等待列表中的位置')
->greeting("您好 {$this->entry->name}!")
->action('验证邮箱', $url);
}
}
// config/waitlist.php
'verification' => [
'enabled' => true,
'notification' => \App\Notifications\CustomVerifyWaitlistEmail::class,
],
将注册同步到 Mailchimp
// config/waitlist.php
'mailing_list' => [
'enabled' => true,
'default' => 'mailchimp',
'drivers' => [
'mailchimp' => [
'key' => env('MAILCHIMP_API_KEY'), // 后缀包含数据中心,例如 -us14
'list_id' => env('MAILCHIMP_LIST_ID'),
],
],
],
// 每个等待列表一个受众(可选——否则使用配置的 list_id)。
Waitlist::for('beta')->connectMailingList('a1b2c3d4e5');
// 自动订阅,取决于配置在添加时或验证时。
Waitlist::for('beta')->add('Jane Smith', '[email protected]');
响应生命周期
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));
测试邮件列表流程
use OffloadProject\Waitlist\Facades\MailingList;
use OffloadProject\Waitlist\Facades\Waitlist;
MailingList::fake();
$entry = Waitlist::add('Joe', '[email protected]');
Waitlist::invite($entry);
MailingList::assertHasSubscriber('[email protected]');