Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
email-sequences-development
a2zwebltd/laravel-email-sequences
claude-code
email-sequences
queues
scheduling
backfill
config
testing

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.

Install
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`.

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

// 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>

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:

$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:
public function up(): void
{
    Artisan::call('email-sequences:sync');
}
  1. 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:
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.

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:

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.

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

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:

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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource