背景
offload-project/laravel-waitlist 是一個 Laravel 11/12/13 套件(PHP 8.3+),用於管理一個或多個候補名單。它提供:
WaitlistEloquent 模型(具名稱與slug的候補名單)與WaitlistEntry模型(等候者)。WaitlistService(透過WaitlistFacade 解析),包含for()、create()、add()、invite()、reject()、sendVerification()、verify()及查詢/計數輔助方法。- 可選的電子郵件驗證流程,內建
/waitlist/verify/{token}路由。 - 可選橋接至
offload-project/laravel-invite-only:呼叫Waitlist::invite()會建立真正的Invitation(包含 token、過期時間、事件),並將外鍵保存於WaitlistEntry::$invitation_id。 - 兩個通知:
WaitlistInvited(經由auto_send_invitation選擇啟用)與VerifyWaitlistEmail。 - 在
OffloadProject\Waitlist\Events中的生命週期事件:WaitlistCreated、WaitlistEntryAdded、WaitlistEntryVerified、WaitlistEntryInvited、WaitlistEntryRejected,以及WaitlistEntrySubscribed、WaitlistEntryUnsubscribed與MailingListSyncFailed。 - 郵件清單整合(
MailingListFacade、MailingListManager),可透過佇列作業將條目同步至 Mailchimp、Kit(ConvertKit)或 Audienceful,並提供log與array驅動程式及extend()擴充鉤子。 - 型別例外:
UnverifiedEntryException(在驗證閘門開啟時對未驗證條目進行邀請)與MailingListException(驅動程式/憑證/清單 ID/API 失敗)。
在使用了 offload-project/laravel-waitlist(存在於 composer.json 中)的 Laravel 專案中,或當使用者請求 Waitlist、WaitlistEntry、Waitlist 或 MailingList Facade 的協助,或是關於此套件的候補名單流程時,請套用此技能。
規則
Facade 使用
- 請使用
WaitlistFacade(OffloadProject\Waitlist\Facades\Waitlist)——不要直接實例化WaitlistService。Facade 是官方支援的進入點。 - 若要指定特定候補名單,請串聯
Waitlist::for($slugOrIdOrModel)->...。若未使用for(...),則所有呼叫會作用於預設候補名單(首次使用時透過getDefault()自動建立)。 Waitlist::for(...)會改變單例服務的內部狀態。對於長時間運行的程序(佇列工作者、Octane),請在每次操作時呼叫for(...),而不是依賴於先前留下的上下文。
候補名單 vs. 條目
- 透過
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(),因為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)觸發驗證——它會產生一個新的 token(generateVerificationToken()會覆蓋任何現有 token)並發送VerifyWaitlistEmail。不要自行產生 token,請使用套件的 token,以確保驗證路由能正常運作。 - 透過
Waitlist::verify($token)確認 token。成功時回傳WaitlistEntry,未知 token 時回傳null。驗證後 token 會被清除(一次性使用)。 - 可透過
waitlist.verification.notification設定自訂驗證通知;它的建構子會接收WaitlistEntry。請從$entry->verification_token讀取 token,並呼叫route('waitlist.verify', ['token' => $entry->verification_token])。 - 套件的驗證路由會掛載在
waitlist.routes.prefix(預設waitlist)下,並使用waitlist.routes.middleware(預設['web'])。若要使用自己的控制器,請設定waitlist.routes.enabled => false,並從自己的 action 中呼叫Waitlist::verify($token)。
狀態與檢查
- 條目狀態為字串常數
pending、invited、rejected。請優先使用$entry->isPending()、isInvited()、isRejected(),而非原始字串比較。 - 驗證狀態存於
verified_at與verification_token。請透過isVerified()與isPendingVerification()檢查,不要比較原始時間戳。 - 若要實現「封鎖直到驗證」的 UI 閘門,請使用
isPendingVerification()(已設定 token,尚未驗證)。僅使用isVerified()會對從未開始驗證的條目回傳false——這兩個狀態是不同的。
Invitable 配置
- 當主機應用程式要邀請人們加入特定實體(團隊、組織、專案)時,請在
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'])。
- 請不要在每個呼叫點硬編碼 invitable。若不同流程需要不同的 invitable,請在
resolver閉包中使用metadata中的鑑別器。
事件
- 請使用套件自有事件來掛鉤生命週期,而不是包裝 Facade 或輪詢資料表。事件位於
OffloadProject\Waitlist\Events,每個事件都將模型作為唯讀屬性攜帶($event->entry、$event->waitlist)。 - 生命週期事件從模型觸發(
WaitlistEntryAdded透過$dispatchesEvents在建立時觸發;其餘從markAsInvited()/markAsRejected()/markAsVerified()觸發),因此即使程式碼繞過 Facade,監聽器仍會執行。 - 在測試中使用
Event::fake([SpecificEvent::class]),而不是裸的Event::fake()——全面的 fake 也會吞掉WaitlistEntryAdded,這會導致郵件清單監聽器永遠無法執行。
郵件清單同步
- 使用
waitlist.mailing_list.enabled開啟此功能,並選擇驅動程式(mailchimp、kit、audienceful、log、array)。使用Waitlist::for($slug)->connectMailingList($listId, $driver = null)將候補名單連接到一個清單——清單 ID 可為 Mailchimp 的 audience ID、Kit 的 form ID(或當list_type為tag時的 tag ID),或 Audienceful 的 publication ID(或當list_type為tag時的 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(),以確保型別轉換、副作用(時間戳、清除 token)與事件保持一致。更好的是:透過 Facade 驅動一切。 - 請不要編輯已發布的遷移檔案來新增欄位——請在宿主應用程式中編寫後續遷移。套件可能在未來版本中新增欄位,並會假定發佈的結構描述不變。
- 請不要繼承
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 邀請將限定於該團隊,且角色為 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;
// 使用 fake 驅動程式
MailingList::fake();
Waitlist::for('beta')->add('Jane', '[email protected]');
MailingList::assertSubscriber('[email protected]');
MailingList::assertHasTag('[email protected]', 'beta');