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.
Requires
- php: ^8.3|^8.4
- laravel/framework: ^11.0|^12.0|^13.0
Requires (Dev)
- fakerphp/faker: ^1.23
- laravel/pail: ^1.2.5
- laravel/pao: ^1.0.6
- laravel/pint: ^1.27
- laravel/tinker: ^3.0
- mockery/mockery: ^1.6
- nunomaduro/collision: ^8.6
- orchestra/testbench: ^11.2
- pestphp/pest: ^5.1
- pestphp/pest-plugin-laravel: ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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)"]
Loading
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
Loading
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
Loading
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
Loading
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.