laravel-atlas-drip maintained by ismail-rt
Laravel Atlas Drip
A headless, state-driven lifecycle & drip campaign engine for Laravel with zero double-sends, automatic cooldowns, and jitter-proof timing.
Built for Laravel SaaS developers, indie hackers, and engineering teams needing robust, code-defined onboarding and retention drip campaigns without paying $200–$1,000/mo for external SaaS marketing tools (Customer.io, Braze, Iterable).
Table of Contents
- The Problem: Why Naive Drip Logic Fails
- How Laravel Atlas Drip Solves It (Architecture & Flow)
- Campaign State Machine
- The Two-Clock Formula
- Signed Unsubscribe Flow
- Installation
- Configuration
- Defining Campaigns
- Artisan Commands
- Events & Observability
- Running Tests
- License
The Problem: Why Naive Drip Logic Fails
Almost every developer writing scheduled emails starts with this naive approach:
// THE NAIVE PATTERN (DANGEROUS IN PRODUCTION)
User::where('email_verified_at', '<=', now()->subDays(3))->get()->each(function ($user) {
Mail::to($user)->send(new OnboardingMail());
});
In production, this pattern causes 6 critical failures:
- The "Catch-Up Blast" (First Deploy Disaster): Deploying this logic to an existing database with 50,000 users causes all 50,000 users to receive Day 3, Day 5, and Day 7 emails simultaneously on the first cron run.
- The "Cron Lag Compression" Bug: If a queue backs up or cron is paused for 4 days, both Day 3 and Day 5 milestones become overdue. On the next run, the user receives both emails back-to-back.
- Double-Sends via Race Conditions: In multi-worker setups, two workers pick up the same batch and email the same user twice within milliseconds without atomic database-level deduplication.
- Cross-Campaign Inbox Bombing: A user triggers Onboarding, Win-Back, and Feature Announcement campaigns on the same day. Without a global cooldown, they receive 3 marketing emails in one afternoon.
- The Zombie Campaign (Ignored Goal Completion): A user signs up, verifies email, and immediately pays for an annual plan. Three days later, naive cron sends: "Hey! Why haven't you explored our app yet?"
- Entangled Unsubscribes: Users clicking "unsubscribe" on a marketing drip get opted out of critical transactional emails (password resets, invoices, security alerts) because there is no separate lifecycle opt-out ledger.
How Laravel Atlas Drip Solves It (Architecture & Flow)
Laravel Atlas Drip evaluates recipients through a strict State Machine + Atomic Deduplication + Two-Clock Pipeline:
flowchart TD
Start(["⏰ Cron: php artisan drip:send"]) --> OptCheck{"1. Opted Out?"}
OptCheck -->|"Yes"| SkipOpt["🚫 Skip Recipient<br/>(Opt-Out Ledger Active)"]
OptCheck -->|"No"| CoolCheck{"2. In 48h Cooldown?"}
CoolCheck -->|"Yes"| SkipCool["🛑 Pause Delivery<br/>(Cross-Campaign Flood Guard)"]
CoolCheck -->|"No"| EnrollCheck{"3. Qualifies for Enrollment?"}
EnrollCheck -->|"Exceeds Max Age"| SkipEnroll["🛡️ Reject Enrollment<br/>(First-Deploy Blast Guard)"]
EnrollCheck -->|"Active / Enrolled"| GoalCheck{"4. Goal Reached?"}
GoalCheck -->|"Yes (cancelWhen)"| CancelState["🎯 Cancel Campaign<br/>(Terminal State: No Zombies)"]
GoalCheck -->|"No"| TimingCheck{"5. Step Due? (Two-Clock)"}
TimingCheck -->|"Not Yet"| WaitTiming["⏳ Wait for Due Date<br/>(Jitter & Lag Guard)"]
TimingCheck -->|"Due"| DedupeTx{"6. Atomic DB Deduplication"}
DedupeTx -->|"Key Exists (Race)"| SkipRace["⚡ Skip Silently<br/>(Zero Double-Sends)"]
DedupeTx -->|"Lock Acquired"| Dispatch["📨 Dispatch Notification / Mailable"]
Dispatch --> StateUpdate["Advance Campaign State<br/>(Record step & timestamp)"]
Campaign State Machine
Each recipient journey is tracked as an explicit, tamper-proof state machine stored in lad_campaign_states:
stateDiagram-v2
[*] --> Active: Recipient Enrolls (anchor_at recorded)
Active --> Active: Step Dispatched (due_at reached, recorded)
Active --> Cancelled: Goal Reached (cancelWhen evaluates true)
Active --> Cancelled: Recipient Opts Out (unsubscribe link clicked)
Active --> Completed: Final Step Dispatched
Cancelled --> [*]: Permanent Terminal State
Completed --> [*]: Permanent Terminal State
note right of Cancelled
Terminal states NEVER re-enroll
or restart, preventing zombie emails.
end note
active: The recipient is moving through sequential steps.completed: All configured steps have been successfully dispatched.cancelled: The user reached the campaign's conversion goal (e.g. upgraded to paid plan) or opted out. Terminal states never re-enroll or restart.
The Two-Clock Formula
When evaluating whether a sequential step is due, the engine computes:
due_at = max(anchor_at + offset, previous_step_sent_at + minimum_gap)
flowchart LR
subgraph ScenarioA["Scenario A: Normal Flow (No Queue Lag)"]
direction TB
A1["Day 0: Anchor Verified"] --> A2["Day 3: Step 1 Sent"]
A2 -->|"Minimum Gap: 2 Days"| A3["Day 5: Step 2 Due & Sent"]
end
subgraph ScenarioB["Scenario B: Cron Delay / Queue Lag (Jitter Protection)"]
direction TB
B1["Day 0: Anchor Verified"] --> B2["Day 4: Step 1 Sent (1 Day Late)"]
B2 -.->|"Naive Cron sends immediately on Day 5"| B_Bad["Day 5: Back-to-Back Inbox Bombing!"]
B2 -->|"Two-Clock Engine: max(Day 5, Day 4 + 2d) = Day 6"| B3["Day 6: Step 2 Safely Sent with Proper Gap"]
end
Why this matters: If Day 3 email sends on Day 4 due to a queue outage, and Day 5 has a minimumGapDays(2), Step 2 will not send on Day 5. It automatically waits until at least Day 6.
Signed Unsubscribe Flow
Laravel Atlas Drip isolates marketing opt-outs from transactional emails (invoices, password resets, security alerts) with a built-in cryptographic HMAC flow:
sequenceDiagram
autonumber
actor User as User
participant App as Laravel Application
participant DB as Database
User->>App: Clicks signed unsubscribe link in email
App->>App: Validate URL HMAC signature (tamper-proof)
alt Invalid or Expired Signature
App-->>User: 403 Forbidden
else Valid Signature
App->>DB: Set marketing_emails_opted_out_at = now()
App->>DB: UPDATE lad_campaign_states SET status='cancelled' WHERE status='active'
App-->>User: Render clean preference confirmation page
Note over User,App: Critical transactional emails (password reset, billing) remain active!
end
Installation
Install the package via Composer:
composer require ismail-rt/laravel-atlas-drip
Publish and run the database migrations:
php artisan vendor:publish --tag=lad-migrations
php artisan migrate
Optionally publish the configuration file and views:
php artisan vendor:publish --tag=lad-config
php artisan vendor:publish --tag=lad-views
Configuration (config/lad.php)
return [
/*
|--------------------------------------------------------------------------
| Engine Enabled
|--------------------------------------------------------------------------
*/
'enabled' => env('LAD_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Global Inter-Campaign Cooldown (Hours)
|--------------------------------------------------------------------------
| If a recipient received ANY lifecycle email in the last X hours,
| subsequent campaign emails are paused to prevent inbox bombing.
*/
'global_cooldown_hours' => (int) env('LAD_COOLDOWN_HOURS', 48),
/*
|--------------------------------------------------------------------------
| Historical Enrollment Cutoff
|--------------------------------------------------------------------------
| Optional timestamp (e.g. '2026-09-01 00:00:00'). Recipients whose anchor
| is prior to this date are rejected from enrollment unless a prior notification
| log proves they were already in the campaign.
*/
'enrollment_started_at' => env('LAD_ENROLLMENT_STARTED_AT', null),
/*
|--------------------------------------------------------------------------
| Default Recipient Model
|--------------------------------------------------------------------------
*/
'recipient_model' => env('LAD_RECIPIENT_MODEL', App\Models\User::class),
/*
|--------------------------------------------------------------------------
| Database Table Names
|--------------------------------------------------------------------------
*/
'table_names' => [
'states' => env('LAD_TABLE_STATES', 'lad_campaign_states'),
'logs' => env('LAD_TABLE_LOGS', 'lad_notification_logs'),
],
/*
|--------------------------------------------------------------------------
| Signed Unsubscribe Routing
|--------------------------------------------------------------------------
*/
'routes' => [
'enabled' => true,
'prefix' => 'lad',
'middleware' => ['web'],
],
];
Defining Campaigns
Register campaigns in your AppServiceProvider or a dedicated service provider using the fluent builder. You can import any of the available facades (Drip, Lifecycle, or Lad):
use Lad\Facades\Drip;
// Aliases available:
// use Lad\Facades\Lifecycle;
// use Lad\Facades\Lad;
use Lad\Campaign;
use Lad\Step;
// 1. Onboarding Campaign
Drip::register(
Campaign::make('onboarding')
->priority(10) // Lower number = evaluated first
// Who qualifies for this campaign?
->eligible(fn ($user) => $user->hasVerifiedEmail() && !$user->isAdmin())
// What is the reference time clock?
->anchor(fn ($user) => $user->email_verified_at)
// Maximum age for initial enrollment (failsafe against first-deploy explosions)
->maxEnrollmentAgeDays(10)
// Goal completion: cancel when user achieves desired action
->cancelWhen(fn ($user) => $user->properties()->exists())
// Sequential steps
->steps([
Step::make('day3')
->offsetDays(3)
->notification(OnboardingReminderNotification::class),
Step::make('day5')
->offsetDays(5)
->minimumGapDays(2)
->notification(OnboardingTipsNotification::class),
Step::make('day7')
->offsetDays(7)
->minimumGapDays(2)
->notification(function ($user) {
return new OnboardingDiscountNotification(promoCode: 'WELCOME15');
}),
])
);
// 2. Recurring Streak Campaign (e.g., Idle Win-Back)
Drip::register(
Campaign::make('idle_winback')
->priority(20)
->eligible(fn ($user) => $user->properties()->exists())
->anchor(fn ($user) => $user->last_login_at)
// Custom instance key isolates recurring streaks
->instanceKey(fn ($user) => $user->last_login_at?->utc()->format('Y-m-d-H-i-s'))
->cancelWhen(fn ($user) => $user->last_login_at?->gt(now()->subDays(7)))
->steps([
Step::make('idle_reminder')
->offsetDays(7)
->notification(WeMissYouNotification::class),
])
);
Flexible Step Offsets & Gaps
Step timings support days, hours, and minutes:
Step::make('welcome_hour2')
->offsetHours(2)
->minimumGapHours(1)
->notification(QuickStartNotification::class);
Artisan Commands
1. Dispatch Due Emails (drip:send / lad:send)
Schedule this command in routes/console.php to run hourly:
use Illuminate\Support\Facades\Schedule;
Schedule::command('drip:send')->hourly();
Command options:
# Execute standard dispatch run (any of these aliases works):
php artisan drip:send
php artisan lifecycle:send
php artisan lad:send
# Dry run: preview dispatches without modifying DB or sending emails
php artisan drip:send --dry-run
# Filter to a specific campaign or recipient
php artisan drip:send --campaign=onboarding
php artisan drip:send --recipient=42
2. Inspect Recipient Status (drip:status / lad:status)
Diagnose recipient eligibility, cooldown window, active campaign states, and upcoming step dates:
php artisan drip:status 42
# Aliases:
php artisan lifecycle:status 42
php artisan lad:status 42
Example tabular output:
Status for Recipient #42:
- Global Cooldown: INACTIVE (Ready)
- Opted Out: NO
- Last Notification Sent At: 2026-09-04 10:00:00
+------------+---------+---------------------+-----------+-----------+---------------------+---------+
| Campaign | Status | Anchor At | Last Step | Next Step | Due At | Is Due? |
+------------+---------+---------------------+-----------+-----------+---------------------+---------+
| onboarding | active | 2026-09-01 10:00:00 | day3 | day5 | 2026-09-06 10:00:00 | YES |
+------------+---------+---------------------+-----------+-----------+---------------------+---------+
3. Manually Cancel Campaign (drip:cancel / lad:cancel)
Manually transition campaign states to cancelled for a recipient:
# Cancel all active campaigns for recipient #42
php artisan drip:cancel 42
# Cancel a specific campaign
php artisan drip:cancel 42 onboarding
# Aliases:
php artisan lifecycle:cancel 42
php artisan lad:cancel 42
Events & Observability
Laravel Atlas Drip dispatches standard Laravel events for metrics, analytics, or logging:
| Event | Dispatched When | Payload |
|---|---|---|
Lad\Events\CampaignEnrolled |
Recipient enters a new campaign | $recipient, $campaign, $state |
Lad\Events\StepDispatched |
Step notification is sent | $recipient, $campaign, $step, $state, $log |
Lad\Events\CampaignCompleted |
Final step is sent | $recipient, $campaign, $state |
Lad\Events\CampaignCancelled |
Goal reached or user opted out | $recipient, $campaign, $state, $reason |
Listen to events in your EventServiceProvider or AppServiceProvider:
use Illuminate\Support\Facades\Event;
use Lad\Events\StepDispatched;
Event::listen(StepDispatched::class, function (StepDispatched $event) {
Log::info("Dispatched {$event->campaign->getName()}:{$event->step->getKey()} to #{$event->recipient->id}");
});
Running Tests
Run the complete test suite with Pest:
vendor/bin/pest
# Or via artisan:
php artisan test
License
The MIT License (MIT). Please see License File for more information.