Community生产力与协作github.com

offload-project/laravel-waitlist

用于管理多个等待列表的 Laravel 包——包含条目状态追踪、可选邮箱验证、生命周期事件、邮件列表同步(Mailchimp/Kit/Audienceful)及与 laravel-invite-only 的桥接。

laravel-waitlist 是什么?

此技能用于指导在 Laravel 应用中使用 `offload-project/laravel-waitlist` 包。该包支持创建和管理一个或多个等待列表,每个列表由 Eloquent 模型表示。它通过 `Waitlist` 门面提供核心操作,如添加条目、邀请、拒绝和验证,并支持自动发送验证邮件。技能文档详细说明了如何配置和使用邮件列表同步功能(支持 Mailchimp、Kit 和 Audienceful),以及如何通过生命周期事件(如条目添加、验证、邀请等)扩展功能。它还涵盖了与 `offload-project/laravel-invite-only` 包的桥接,以便在邀请时自动创建邀请记录。该技能适用于 Laravel 11/12/13 和 PHP 8.3+ 环境。

兼容平台✓Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/offload-project/laravel-waitlist/tree/HEAD/skills

Installed? Explore more 生产力与协作 skills: steipete/gemini, steipete/gh-issues, steipete/skill-creator · View all 6 →

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

上下文

offload-project/laravel-waitlist 是一个 Laravel 11/12/13 包(PHP 8.3+),用于管理一个或多个等待列表。它提供了:

  • 一个 Waitlist Eloquent 模型(带有 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 门面或此包中的等待列表流程时,应用此技能。

规则

门面使用

  1. 使用 Waitlist 门面(OffloadProject\Waitlist\Facades\Waitlist)——不要直接实例化 WaitlistService。门面是支持的入口点。
  2. 要定位特定等待列表,链式调用 Waitlist::for($slugOrIdOrModel)->...。如果没有 for(...),调用将操作默认等待列表(首次使用时会通过 getDefault() 自动创建)。
  3. Waitlist::for(...) 会修改单例服务的内部状态。对于长时间运行的进程(队列工作者、Octane),每次操作都调用 for(...),而不是依赖之前上下文调用的持久性。

等待列表与条目

  1. 使用 Waitlist::create(string $name, string $slug, ?string $description = null, bool $isActive = true) 创建等待列表。slug 是规范标识符——for(...) 和 find(...) 期望的就是它。
  2. 通过 Waitlist::for($slug)->add($name, $email, $metadata = []) 添加条目。当希望验证流程运行时,不要直接调用 WaitlistEntry::create([...])——当 waitlist.verification.enabled 为 true 时,add() 会自动触发 sendVerification()。
  3. waitlist_entries 上的唯一约束是 ['waitlist_id', 'email'],而不仅仅是 email。同一个人可以加入多个等待列表。

邀请

  1. 通过 Waitlist::invite($entryOrId, $options = []) 邀请。不要单独调用 $entry->markAsInvited(),当希望发送通知和创建 Invitation 记录时——invite() 会创建 laravel-invite-only 的 Invitation,通过 invitation_id 链接它,并将条目标记为已邀请。
  2. $options 会传递给 InviteOnly::invite(...)。常见键:'invited_by'(Model 或 int——默认为 auth()->user())、'role'、'metadata'、'expires_at'。不要重复在 waitlist.invitable.metadata_mapper 中已设置的键——显式的 $options 通过 array_merge 胜出。
  3. 如果 waitlist.verification.enabled 为 true 且 waitlist.verification.require_before_invite 为 true,则对未验证条目调用 invite() 会抛出 UnverifiedEntryException。在用户面向的流程中显式捕获它;不要将其埋藏在泛型 \Throwable 下。
  4. WaitlistInvited 通知是选择加入的(waitlist.auto_send_invitation 默认为 false)。来自 laravel-invite-only 的邀请通知无论如何都会发送。仅当希望在之上发送第二个等待列表品牌邮件时,才启用 auto_send_invitation。

验证

  1. 通过 Waitlist::sendVerification($entry) 触发验证——它会生成一个新鲜令牌(generateVerificationToken() 会覆盖任何现有令牌)并发送 VerifyWaitlistEmail。不要自行生成令牌;使用包的令牌,以便验证路由保持工作。
  2. 通过 Waitlist::verify($token) 确认令牌。成功时返回 WaitlistEntry,未知令牌时返回 null。验证后令牌会被清除(一次性使用)。
  3. 通过 waitlist.verification.notification 配置自定义验证通知;它在其构造函数中接收 WaitlistEntry。从 $entry->verification_token 读取令牌并调用 route('waitlist.verify', ['token' => $entry->verification_token])。
  4. 包的验证路由挂载在 waitlist.routes.prefix(默认为 waitlist)下,使用 waitlist.routes.middleware(默认为 ['web'])。要使用自己的控制器,设置 waitlist.routes.enabled => false 并从你的动作中调用 Waitlist::verify($token)。

状态与检查

  1. 条目状态是字符串字面量 pending、invited、rejected。优先使用 $entry->isPending()、isInvited()、isRejected() 而不是原始字符串比较。
  2. 验证状态位于 verified_at 和 verification_token 上。通过 isVerified() 和 isPendingVerification() 检查;不要比较原始时间戳。
  3. 对于“阻止直到验证”的 UI 门控,使用 isPendingVerification()(令牌已设置,但尚未验证)。isVerified() 单独对从未开始验证的条目返回 false——这两种状态是不同的。

可邀请模型配置

  1. 当宿主应用邀请人们加入特定实体(团队、组织、项目)时,在 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'])。
  2. 不要在每次调用时硬编码可邀请模型。如果不同流程需要不同的可邀请模型,请使用带元数据鉴别器的 resolver 闭包。

事件

  1. 通过包自身的事件钩入生命周期,而不是包装门面或轮询表。它们位于 OffloadProject\Waitlist\Events 中,每个事件将模型作为只读属性携带($event->entry、$event->waitlist)。
  2. 生命周期事件从模型触发(创建时通过 $dispatchesEvents 的 WaitlistEntryAdded;其余的来自 markAsInvited() / markAsRejected() / markAsVerified()),因此即使代码绕过门面,监听器仍然运行。
  3. 在测试中使用 Event::fake([SpecificEvent::class]),而不是裸 Event::fake()——全假也会吞掉 WaitlistEntryAdded,从而阻止邮件列表监听器运行。

邮件列表同步

  1. 使用 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。
  2. 不要构建自己的“注册时订阅”监听器。订阅是自动的,并遵循验证设置:当 waitlist.verification.enabled 关闭时,条目在 add() 时同步;当它开启时,条目在 Waitlist::verify() 后同步。这种顺序是有意的——未确认的地址绝不能到达新闻通讯。
  3. 对于超出订阅的操作——邀请时打标签、拒绝时移除、列表间移动——监听生命周期事件并调用 MailingList::tagEntry($entry, [...]) 或 Waitlist::unsubscribeFromMailingList($entry)。不要将这些副作用添加到宿主应用的控制器中。
  4. 同步通过队列任务运行(SyncEntryToMailingList、UnsubscribeEntryFromMailingList)。在生产环境中保持 mailing_list.queue.enabled 开启,以便注册永远不会阻塞在提供商 API 上。使用 php artisan waitlist:sync-mailing-list [slug] [--all] [--force] 或 Waitlist::for($slug)->syncMailingList() 回填现有行。
  5. 添加包未提供的服务,通过实现 OffloadProject\Waitlist\Contracts\MailingListDriver 并从服务提供者中使用 MailingList::extend('name', fn (array $config) => new YourDriver(...)) 注册它。不要派生已提供的驱动。
  6. 在测试中使用 MailingList::fake(),它会切换到内存中的 ArrayDriver,内联运行同步,并暴露 hasSubscriber()、subscribers() 和 tagsFor()。仅当需要断言真实驱动发送的确切请求时才使用 Http::fake()。

禁止操作

  1. 不要通过直接 update() 调用($entry->update(['status' => 'invited']))运行生命周期更改。使用 markAsInvited() / markAsRejected() / markAsVerified(),以便类型转换、副作用(时间戳、令牌清除)和事件保持一致。更好的做法:通过门面驱动所有操作。
  2. 不要编辑已发布的迁移来添加列——在宿主应用中编写后续迁移。包可能会在将来的版本中添加列,并假定使用发布的模式。
  3. 不要子类化 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]');

相关技能