Build and change drip / lifecycle emails with a2zwebltd/laravel-email-sequences — defining sequences in config/email-sequences.php, writing their Markdown views, gate classes (should_enroll, should_continue, should_send), SequenceDelivered listeners, the email-sequences:sync / emails:backfill-enrollments / emails:deliver-sequences commands, and testing. Use when touching HasEmailSequences, EmailSequence, EmailSequenceDelivery, SequenceEmail or adding, re-timing or removing a sequence.
php artisan boost:add-skill a2zwebltd/laravel-email-sequences
Save this content to: .claude/skills/email-sequences-development/SKILL.md
# Email sequences development
## When to use this skill
- Adding, renaming, re-timing or removing a sequence in `config/email-sequences.php`.
- Writing a sequence email view, a gate class or a `SequenceDelivered` listener.
- Enrolling existing users into a new sequence (targeted backfill).
- Testing sequence enrolment and delivery.
## Install / wiring checklist
1. `composer require a2zwebltd/laravel-email-sequences`
2. `php artisan vendor:publish --tag=email-sequences-config` and replace the default `sequences` (four trial emails) BEFORE the first `php artisan migrate`. The first migrate seeds `email_sequences` from config, and `auto_enroll` defaults to `true`.
3. Add `A2ZWeb\EmailSequences\Concerns\HasEmailSequences` to the user model (`user_model` defaults to `auth.providers.users.model`).
4. `php artisan migrate`
5. Run a queue worker: `SequenceEmail` implements `ShouldQueue`.
6. Leave scheduling to the package: it registers `emails:deliver-sequences` itself (hourly, `withoutOverlapping`). Do not add it to `routes/console.php`.
7. Optional: `php artisan vendor:publish --tag=email-sequences-views` to rebrand the bundled views.
## API & config reference
Config `config/email-sequences.php`:
| Key | Meaning |
|-----|---------|
| `user_model` | Enrolled model class |
| `sequences.<code>.name` | Label stored on the `email_sequences` row |
| `sequences.<code>.days_delay` | Days after the user's `created_at` (read from the DB row, see sync) |
| `sequences.<code>.subject` | Subject, passed through `__()` in the recipient's locale; `:name` is replaced with the recipient name |
| `sequences.<code>.view` | Markdown mail view; gets `$user` and `$name` |
| `sequences.<code>.should_send` | Optional per-email gate, invokable class-string |
| `sequences.<code>.reply_to` | Optional Reply-To address for this email; overrides `mail.reply_to` |
| `mail.reply_to` | Reply-To for every sequence email (env `EMAIL_SEQUENCES_REPLY_TO`, default `null`) |
| `auto_enroll` | Enrol on `eloquent.created` of `user_model` (env `EMAIL_SEQUENCES_AUTO_ENROLL`) |
| `should_enroll` | Gate for enrolment, invokable class-string or `null` |
| `should_continue` | Gate checked per due delivery; `false` deletes all the user's pending deliveries |
| `schedule.deliver` / `schedule.cron` | Self-scheduling; cron is `hourly`, `daily`, `everyMinute` or a cron expression |
| `schedule.without_overlapping` | Default `true`; 60-minute mutex |
| `schedule.on_one_server` | Default `false`; needs a cache store with atomic locks |
| `nova.register_resources` / `nova.group` / `nova.user_resource` | Nova resources (only when Nova is installed) |
Classes:
- `HasEmailSequences` trait: `emailSequenceDeliveries(): HasMany`, `enrollInEmailSequences(): void` (idempotent `firstOrCreate` per sequence, oldest live row per code wins).
- `Models\EmailSequence` (`sequence_code`, `name`, `days_delay`, soft deletes) and `Models\EmailSequenceDelivery` (`user_id`, `email_sequence_id`, `scheduled_at`, `sent_at`, soft deletes).
- `Mail\SequenceEmail(Model $user, string $sequenceCode)`: queued, `$tries = 3`, backoff 60/300/900 s. Uses `getMailingEmail()` / `getPersonalizedName()` on the user when defined, else `email` and the first word of `name`. When the user implements `HasLocalePreference` and `preferredLocale()` is non-empty, the constructor calls `$this->locale(...)`; the locale is serialised with the queued mail and the subject and view are built inside it at send time.
- `Events\SequenceDelivered(Model $user, string $sequenceCode, EmailSequenceDelivery $delivery)`: fired after the mail is queued.
Commands:
| Command | What it does |
|---------|--------------|
| `emails:deliver-sequences` | Sends every delivery with `sent_at` null and `scheduled_at <= now()` |
| `emails:backfill-enrollments` | Calls `enrollInEmailSequences()` for users with no deliveries at all |
| `email-sequences:sync` | Upserts `email_sequences` rows from config by `sequence_code`; `--dry-run`, `--prune` (soft-delete codes gone from config), `--reschedule` (shift pending deliveries by the `days_delay` change) |
## Recipes
### Define a sequence
```php
// config/email-sequences.php
'sequences' => [
'welcome' => [
'name' => 'Welcome',
'days_delay' => 0,
'subject' => 'Welcome aboard, :name!',
'view' => 'emails.sequences.welcome',
'should_send' => \App\EmailSequences\HasNotActivated::class, // optional
],
],
```
The view is a Markdown mailable, so wrap it in the mail message component:
```blade
<x-mail::message>
# Hi {{ $name }},
Thanks for signing up. Your account email is {{ $user->email }}.
<x-mail::button :url="route('dashboard')">Open your dashboard</x-mail::button>
</x-mail::message>
```
### Derive days_delay from env
Config files load alphabetically, so `config('other-file.key')` inside `config/email-sequences.php` may not be loaded yet. Read the env var directly, as cvmatchscore does:
```php
$trialDays = (int) env('REPORTS_FREE_TRIAL_DAYS', 7);
return [
'sequences' => [
'trial_ending' => ['name' => 'Trial Ending Soon', 'days_delay' => max(1, $trialDays - 2), /* ... */],
],
];
```
Changing the env value changes config only. Run `php artisan email-sequences:sync` after deploying the change.
### Add or change a sequence safely
1. Edit `sequences` in config.
2. `php artisan email-sequences:sync --dry-run`, then `php artisan email-sequences:sync`. Add `--prune` when you removed a code, `--reschedule` if pending deliveries should move with a new `days_delay` (by default they keep their date).
3. To make the deploy hands-free, call the command from a host migration:
```php
public function up(): void
{
Artisan::call('email-sequences:sync');
}
```
4. New users are enrolled into the new code automatically. Existing users are not: `emails:backfill-enrollments` skips anyone who already has a delivery. Write a targeted, staggered backfill instead:
```php
use A2ZWeb\EmailSequences\Models\EmailSequence;
use A2ZWeb\EmailSequences\Models\EmailSequenceDelivery;
$sequence = EmailSequence::where('sequence_code', 'welcome')->orderBy('id')->firstOrFail();
$firstSlot = now()->addHour()->startOfHour();
$perHour = 50;
$index = 0;
User::query()
// withTrashed: a delivery soft-deleted by should_continue must still count.
->whereDoesntHave('emailSequenceDeliveries', fn ($q) => $q->withTrashed()->where('email_sequence_id', $sequence->id))
->chunkById(500, function ($users) use ($sequence, $firstSlot, $perHour, &$index): void {
foreach ($users as $user) {
EmailSequenceDelivery::firstOrCreate(
['user_id' => $user->id, 'email_sequence_id' => $sequence->id],
['scheduled_at' => $firstSlot->copy()->addHours(intdiv($index++, $perHour))],
);
}
});
```
Filter the query to the audience that should get the email, and ship it with a dry-run default.
### Gate classes
Gates are resolved with `app($class)` when the config value is a class-string, then called with the user. Keep them as classes so `php artisan config:cache` works.
```php
namespace App\EmailSequences;
use App\Models\User;
class ShouldEnrollInSequences // should_enroll: false = never enrolled
{
public function __invoke(User $user): bool
{
return ! $user->is_admin;
}
}
class ShouldContinueSequence // should_continue: false = ALL pending deliveries deleted
{
public function __invoke(User $user): bool
{
return ! $user->subscribed();
}
}
class HasNotActivated // sequences.<code>.should_send: false = this email marked sent, never retried
{
public function __invoke(User $user): bool
{
return $user->activated_at === null;
}
}
```
### Send sequences in the user's language
Implement `HasLocalePreference` on the user model and translate each subject in `lang/{locale}.json`, keyed by the subject exactly as it appears in config:
```php
use Illuminate\Contracts\Translation\HasLocalePreference;
class User extends Authenticatable implements HasLocalePreference
{
public function preferredLocale(): ?string
{
return $this->locale; // null or '' = app locale
}
}
```
```json
// lang/pl.json
{ "Welcome aboard, :name!": "Witamy na pokładzie, :name!" }
```
The view renders in the same locale, so use `__()` inside it, or branch on `app()->getLocale()` for whole-body variants. The locale is read when `emails:deliver-sequences` builds the mail, not at enrolment, so a user who changes language before the send date gets the new one.
### Reply-To
Set `EMAIL_SEQUENCES_REPLY_TO` (config `mail.reply_to`) for all sequence emails, or `'reply_to' => '[email protected]'` on one sequence entry. The per-sequence value wins. Keep them plain strings so `config:cache` works.
### Listen to SequenceDelivered
```php
namespace App\Listeners;
use A2ZWeb\EmailSequences\Events\SequenceDelivered;
use App\Models\User;
class MirrorSequenceToDashboard
{
public function handle(SequenceDelivered $event): void
{
if (! $event->user instanceof User) {
return;
}
if ($event->sequenceCode === 'welcome') {
$event->user->notify(new \App\Notifications\WelcomeNotice);
}
}
}
```
The event fires when the mail is queued, not when it reaches the inbox.
## Gotchas
- The first migrate seeds `email_sequences` once. Config edits reach the table only through `email-sequences:sync`.
- Never insert `email_sequences` rows by hand or with a non-idempotent script: duplicate live rows per code were how users got every email twice. The sync command warns about duplicates and only touches the oldest live row.
- A code in the DB but not in config is still delivered, with the generic view and the subject "A message for you". Use `email-sequences:sync --prune`.
- Deliveries on a soft-deleted sequence row stay pending and are skipped, not sent.
- `emails:backfill-enrollments` schedules from `created_at`: long-standing users get every overdue email on the next delivery run.
- `should_send` returning `false` sets `sent_at`, so the email is skipped for good even if the gate later passes.
- A closure in config works at runtime but breaks `config:cache`. Use invokable class-strings.
- A subject translation must use the config subject as its key, character for character (including curly quotes). Change the config subject and every translation key goes stale; the untranslated subject is sent.
## Testing
Use `Mail::fake()`, move a delivery into the past, then run the command. `SequenceEmail` is queued, so assert with `assertQueued`:
```php
use A2ZWeb\EmailSequences\Mail\SequenceEmail;
use Illuminate\Support\Facades\Mail;
test('the delivery command sends due emails and marks them sent', function () {
Mail::fake();
$user = User::factory()->create(); // auto-enrolled
$delivery = $user->emailSequenceDeliveries()
->whereHas('emailSequence', fn ($q) => $q->where('sequence_code', 'welcome'))
->first();
$delivery->update(['scheduled_at' => now()->subMinute()]);
$this->artisan('emails:deliver-sequences')->assertSuccessful();
expect($delivery->fresh()->sent_at)->not->toBeNull();
Mail::assertQueued(SequenceEmail::class);
});
```
- Rows exist in tests because the package migration seeds them from config during `RefreshDatabase`. If a test changes `sequences` at runtime, run `$this->artisan('email-sequences:sync')` before creating users.
- Set `config()->set('email-sequences.auto_enroll', false)` to create users without deliveries.
- Render an email directly: `(new SequenceEmail($user, 'welcome'))->render()`, and check `->envelope()->subject`.
- For the localised subject, use `$mail->assertHasSubject('...')`: it builds the envelope inside the mail's locale. `->envelope()->subject` called directly is built in the current app locale. Register a line in the test with `app('translator')->addLines(['*.Welcome aboard, :name!' => 'Witamy, :name!'], 'pl')`, and check `$mail->locale`.
config/email-sequences.php.SequenceDelivered listener.composer require a2zwebltd/laravel-email-sequencesphp artisan vendor:publish --tag=email-sequences-config and replace the default sequences (four trial emails) BEFORE the first php artisan migrate. The first migrate seeds email_sequences from config, and auto_enroll defaults to true.A2ZWeb\EmailSequences\Concerns\HasEmailSequences to the user model (user_model defaults to auth.providers.users.model).php artisan migrateSequenceEmail implements ShouldQueue.emails:deliver-sequences itself (hourly, withoutOverlapping). Do not add it to routes/console.php.php artisan vendor:publish --tag=email-sequences-views to rebrand the bundled views.Config config/email-sequences.php:
| Key | Meaning |
|---|---|
user_model |
Enrolled model class |
sequences.<code>.name |
Label stored on the email_sequences row |
sequences.<code>.days_delay |
Days after the user's created_at (read from the DB row, see sync) |
sequences.<code>.subject |
Subject, passed through __() in the recipient's locale; :name is replaced with the recipient name |
sequences.<code>.view |
Markdown mail view; gets $user and $name |
sequences.<code>.should_send |
Optional per-email gate, invokable class-string |
sequences.<code>.reply_to |
Optional Reply-To address for this email; overrides mail.reply_to |
mail.reply_to |
Reply-To for every sequence email (env EMAIL_SEQUENCES_REPLY_TO, default null) |
auto_enroll |
Enrol on eloquent.created of user_model (env EMAIL_SEQUENCES_AUTO_ENROLL) |
should_enroll |
Gate for enrolment, invokable class-string or null |
should_continue |
Gate checked per due delivery; false deletes all the user's pending deliveries |
schedule.deliver / schedule.cron |
Self-scheduling; cron is hourly, daily, everyMinute or a cron expression |
schedule.without_overlapping |
Default true; 60-minute mutex |
schedule.on_one_server |
Default false; needs a cache store with atomic locks |
nova.register_resources / nova.group / nova.user_resource |
Nova resources (only when Nova is installed) |
Classes:
HasEmailSequences trait: emailSequenceDeliveries(): HasMany, enrollInEmailSequences(): void (idempotent firstOrCreate per sequence, oldest live row per code wins).Models\EmailSequence (sequence_code, name, days_delay, soft deletes) and Models\EmailSequenceDelivery (user_id, email_sequence_id, scheduled_at, sent_at, soft deletes).Mail\SequenceEmail(Model $user, string $sequenceCode): queued, $tries = 3, backoff 60/300/900 s. Uses getMailingEmail() / getPersonalizedName() on the user when defined, else email and the first word of name. When the user implements HasLocalePreference and preferredLocale() is non-empty, the constructor calls $this->locale(...); the locale is serialised with the queued mail and the subject and view are built inside it at send time.Events\SequenceDelivered(Model $user, string $sequenceCode, EmailSequenceDelivery $delivery): fired after the mail is queued.Commands:
| Command | What it does |
|---|---|
emails:deliver-sequences |
Sends every delivery with sent_at null and scheduled_at <= now() |
emails:backfill-enrollments |
Calls enrollInEmailSequences() for users with no deliveries at all |
email-sequences:sync |
Upserts email_sequences rows from config by sequence_code; --dry-run, --prune (soft-delete codes gone from config), --reschedule (shift pending deliveries by the days_delay change) |
// config/email-sequences.php
'sequences' => [
'welcome' => [
'name' => 'Welcome',
'days_delay' => 0,
'subject' => 'Welcome aboard, :name!',
'view' => 'emails.sequences.welcome',
'should_send' => \App\EmailSequences\HasNotActivated::class, // optional
],
],
The view is a Markdown mailable, so wrap it in the mail message component:
<x-mail::message>
# Hi {{ $name }},
Thanks for signing up. Your account email is {{ $user->email }}.
<x-mail::button :url="route('dashboard')">Open your dashboard</x-mail::button>
</x-mail::message>
Config files load alphabetically, so config('other-file.key') inside config/email-sequences.php may not be loaded yet. Read the env var directly, as cvmatchscore does:
$trialDays = (int) env('REPORTS_FREE_TRIAL_DAYS', 7);
return [
'sequences' => [
'trial_ending' => ['name' => 'Trial Ending Soon', 'days_delay' => max(1, $trialDays - 2), /* ... */],
],
];
Changing the env value changes config only. Run php artisan email-sequences:sync after deploying the change.
sequences in config.php artisan email-sequences:sync --dry-run, then php artisan email-sequences:sync. Add --prune when you removed a code, --reschedule if pending deliveries should move with a new days_delay (by default they keep their date).public function up(): void
{
Artisan::call('email-sequences:sync');
}
emails:backfill-enrollments skips anyone who already has a delivery. Write a targeted, staggered backfill instead:use A2ZWeb\EmailSequences\Models\EmailSequence;
use A2ZWeb\EmailSequences\Models\EmailSequenceDelivery;
$sequence = EmailSequence::where('sequence_code', 'welcome')->orderBy('id')->firstOrFail();
$firstSlot = now()->addHour()->startOfHour();
$perHour = 50;
$index = 0;
User::query()
// withTrashed: a delivery soft-deleted by should_continue must still count.
->whereDoesntHave('emailSequenceDeliveries', fn ($q) => $q->withTrashed()->where('email_sequence_id', $sequence->id))
->chunkById(500, function ($users) use ($sequence, $firstSlot, $perHour, &$index): void {
foreach ($users as $user) {
EmailSequenceDelivery::firstOrCreate(
['user_id' => $user->id, 'email_sequence_id' => $sequence->id],
['scheduled_at' => $firstSlot->copy()->addHours(intdiv($index++, $perHour))],
);
}
});
Filter the query to the audience that should get the email, and ship it with a dry-run default.
Gates are resolved with app($class) when the config value is a class-string, then called with the user. Keep them as classes so php artisan config:cache works.
namespace App\EmailSequences;
use App\Models\User;
class ShouldEnrollInSequences // should_enroll: false = never enrolled
{
public function __invoke(User $user): bool
{
return ! $user->is_admin;
}
}
class ShouldContinueSequence // should_continue: false = ALL pending deliveries deleted
{
public function __invoke(User $user): bool
{
return ! $user->subscribed();
}
}
class HasNotActivated // sequences.<code>.should_send: false = this email marked sent, never retried
{
public function __invoke(User $user): bool
{
return $user->activated_at === null;
}
}
Implement HasLocalePreference on the user model and translate each subject in lang/{locale}.json, keyed by the subject exactly as it appears in config:
use Illuminate\Contracts\Translation\HasLocalePreference;
class User extends Authenticatable implements HasLocalePreference
{
public function preferredLocale(): ?string
{
return $this->locale; // null or '' = app locale
}
}
// lang/pl.json
{ "Welcome aboard, :name!": "Witamy na pokładzie, :name!" }
The view renders in the same locale, so use __() inside it, or branch on app()->getLocale() for whole-body variants. The locale is read when emails:deliver-sequences builds the mail, not at enrolment, so a user who changes language before the send date gets the new one.
Set EMAIL_SEQUENCES_REPLY_TO (config mail.reply_to) for all sequence emails, or 'reply_to' => '[email protected]' on one sequence entry. The per-sequence value wins. Keep them plain strings so config:cache works.
namespace App\Listeners;
use A2ZWeb\EmailSequences\Events\SequenceDelivered;
use App\Models\User;
class MirrorSequenceToDashboard
{
public function handle(SequenceDelivered $event): void
{
if (! $event->user instanceof User) {
return;
}
if ($event->sequenceCode === 'welcome') {
$event->user->notify(new \App\Notifications\WelcomeNotice);
}
}
}
The event fires when the mail is queued, not when it reaches the inbox.
email_sequences once. Config edits reach the table only through email-sequences:sync.email_sequences rows by hand or with a non-idempotent script: duplicate live rows per code were how users got every email twice. The sync command warns about duplicates and only touches the oldest live row.email-sequences:sync --prune.emails:backfill-enrollments schedules from created_at: long-standing users get every overdue email on the next delivery run.should_send returning false sets sent_at, so the email is skipped for good even if the gate later passes.config:cache. Use invokable class-strings.Use Mail::fake(), move a delivery into the past, then run the command. SequenceEmail is queued, so assert with assertQueued:
use A2ZWeb\EmailSequences\Mail\SequenceEmail;
use Illuminate\Support\Facades\Mail;
test('the delivery command sends due emails and marks them sent', function () {
Mail::fake();
$user = User::factory()->create(); // auto-enrolled
$delivery = $user->emailSequenceDeliveries()
->whereHas('emailSequence', fn ($q) => $q->where('sequence_code', 'welcome'))
->first();
$delivery->update(['scheduled_at' => now()->subMinute()]);
$this->artisan('emails:deliver-sequences')->assertSuccessful();
expect($delivery->fresh()->sent_at)->not->toBeNull();
Mail::assertQueued(SequenceEmail::class);
});
RefreshDatabase. If a test changes sequences at runtime, run $this->artisan('email-sequences:sync') before creating users.config()->set('email-sequences.auto_enroll', false) to create users without deliveries.(new SequenceEmail($user, 'welcome'))->render(), and check ->envelope()->subject.$mail->assertHasSubject('...'): it builds the envelope inside the mail's locale. ->envelope()->subject called directly is built in the current app locale. Register a line in the test with app('translator')->addLines(['*.Welcome aboard, :name!' => 'Witamy, :name!'], 'pl'), and check $mail->locale.How can I help you explore Laravel packages today?