Laravel Cart Abandonment: Detect, Track and Recover Lost Sales with Automated Email Reminders
TL;DR: Create a cart_abandonments table. Detect stale carts every 15 minutes with a scheduled command. Send a three-step email sequence at 1 hour, 24 hours, and 72 hours. Restore the cart with a single signed token link. Mark recovered carts and track attribution. Abandoned cart emails account for up to 76% of e-commerce automation revenue — this is the highest-ROI feature you can add.
This post builds on PHP Shopping Cart Session Management Best Practices. The persistence layer there is what makes recovery emails possible.
The cart_abandonments Table
Schema::create('cart_abandonments', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained()->onDelete('cascade');
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->string('recovery_token')->unique(); // signed token for cart restore link
$table->boolean('email_sent')->default(false);
$table->timestamp('email_sent_at')->nullable();
$table->boolean('recovered')->default(false);
$table->timestamp('recovered_at')->nullable();
$table->integer('reminder_count')->default(0);
$table->timestamp('last_reminder_sent_at')->nullable();
$table->json('snapshot')->nullable(); // items + total at abandonment time
$table->timestamps();
$table->index('email');
$table->index(['recovered', 'email_sent_at']);
$table->index('recovery_token');
});The CartAbandonment Model
// Modules/Cart/Models/CartAbandonment.php
class CartAbandonment extends Model
{
protected $fillable = [
'cart_id', 'email', 'phone', 'recovery_token',
'email_sent', 'email_sent_at',
'recovered', 'recovered_at',
'reminder_count', 'last_reminder_sent_at', 'snapshot',
];
protected $casts = [
'email_sent' => 'boolean',
'email_sent_at' => 'datetime',
'recovered' => 'boolean',
'recovered_at' => 'datetime',
'last_reminder_sent_at' => 'datetime',
'snapshot' => 'array',
];
public function cart(): BelongsTo
{
return $this->belongsTo(Cart::class);
}
public function markRecovered(): void
{
$this->update([
'recovered' => true,
'recovered_at' => now(),
]);
}
public function logReminder(): void
{
$this->increment('reminder_count');
$this->update(['last_reminder_sent_at' => now()]);
}
}Step 1 — Detect Abandoned Carts
A cart is abandoned when it has items, has no order attached, and hasn't been updated in over an hour.
// app/Console/Commands/DetectAbandonedCarts.php
class DetectAbandonedCarts extends Command
{
protected $signature = 'cart:scan-abandoned';
protected $description = 'Flag carts inactive for more than 1 hour as abandoned';
public function handle(): void
{
Cart::query()
->whereNull('order_id')
->where('updated_at', '<=', now()->subHour())
->whereDoesntHave('abandonment')
->whereHas('items')
->whereNotNull('user_id')
->with(['items', 'user'])
->chunk(100, function ($carts) {
foreach ($carts as $cart) {
CartAbandonment::create([
'cart_id' => $cart->id,
'email' => $cart->user?->email,
'recovery_token' => Str::random(64),
'snapshot' => [
'name' => $cart->user?->name,
'currency' => $cart->currency,
'total' => $cart->total,
'items' => $cart->items->map(fn($i) => [
'name' => $i->name,
'qty' => $i->qty,
'price' => $i->price,
'subtotal' => $i->subtotal,
])->toArray(),
],
]);
}
});
}
}Step 2 — The Recovery Mailable
// app/Mail/CartRecoveryReminder.php
class CartRecoveryReminder extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly CartAbandonment $abandonment,
public readonly int $step = 1,
) {}
public function envelope(): Envelope
{
$subjects = [
1 => "You left something behind — your cart is saved",
2 => "Still thinking? Your cart expires soon",
3 => "Last chance — items in your cart are in limited supply",
];
return new Envelope(
to: $this->abandonment->email,
subject: $subjects[$this->step] ?? $subjects[1],
);
}
public function content(): Content
{
return new Content(view: 'emails.cart-recovery');
}
}{{-- resources/views/emails/cart-recovery.blade.php --}}
<h2>Hi {{ $abandonment->snapshot['name'] ?? 'there' }},</h2>
<p>You left {{ count($abandonment->snapshot['items']) }} item(s) in your cart:</p>
<ul>
@foreach ($abandonment->snapshot['items'] as $item)
<li>{{ $item['name'] }} × {{ $item['qty'] }} — {{ $item['price'] }}</li>
@endforeach
</ul>
<p><strong>Total: {{ $abandonment->snapshot['total'] }} {{ $abandonment->snapshot['currency'] }}</strong></p>
<a href="{{ url('/cart/recover/' . $abandonment->recovery_token) }}">
Return to My Cart →
</a>Transactional email: Use Moosend or Postmark for reliable delivery. Laravel's mail config handles both out of the box.
Step 3 — Send Timed Sequences
// app/Console/Commands/SendCartRecoveryEmails.php
class SendCartRecoveryEmails extends Command
{
protected $signature = 'cart:send-recovery';
protected $description = 'Send 3-step cart recovery email sequence';
public function handle(): void
{
// Step 1: 1 hour after abandonment
CartAbandonment::query()
->where('email_sent', false)
->where('recovered', false)
->whereNotNull('email')
->whereHas('cart', fn($q) => $q->where('updated_at', '<=', now()->subHour()))
->each(function ($a) {
Mail::queue(new CartRecoveryReminder($a, 1));
$a->update(['email_sent' => true, 'email_sent_at' => now()]);
$a->logReminder();
});
// Step 2: 24 hours after first email
CartAbandonment::query()
->where('email_sent', true)
->where('recovered', false)
->where('reminder_count', 1)
->where('last_reminder_sent_at', '<=', now()->subHours(24))
->each(function ($a) {
Mail::queue(new CartRecoveryReminder($a, 2));
$a->logReminder();
});
// Step 3: 72 hours after second email
CartAbandonment::query()
->where('recovered', false)
->where('reminder_count', 2)
->where('last_reminder_sent_at', '<=', now()->subHours(72))
->each(function ($a) {
Mail::queue(new CartRecoveryReminder($a, 3));
$a->logReminder();
});
}
}Step 4 — The Recovery Route
Route::get('/cart/recover/{token}', function (string $token) {
$abandonment = CartAbandonment::where('recovery_token', $token)
->where('recovered', false)
->firstOrFail();
app('cart')->restoreFromDatabase($abandonment->cart->identifier, false);
$abandonment->markRecovered();
return redirect()->route('checkout.index')
->with('success', 'Welcome back — your cart has been restored.');
})->name('cart.recover');Step 5 — Schedule Everything
// routes/console.php
Schedule::command('cart:scan-abandoned')->everyFifteenMinutes();
Schedule::command('cart:send-recovery')->everyThirtyMinutes();
// Purge old unrecovered records after 30 days
Schedule::call(function () {
CartAbandonment::where('recovered', false)
->where('created_at', '<=', now()->subDays(30))
->delete();
})->weekly();# Cron entry
* * * * * cd /var/www/html && php artisan schedule:run >> /dev/null 2>&1Recovery Rate Reporting
$period = now()->subDays(30);
$total = CartAbandonment::where('created_at', '>=', $period)->count();
$recovered = CartAbandonment::where('recovered', true)->where('created_at', '>=', $period)->count();
$rate = $total > 0 ? round(($recovered / $total) * 100, 1) : 0;
// → "23.4%"
// Top abandoned products
$topAbandoned = CartAbandonment::where('recovered', false)
->pluck('snapshot')
->flatMap(fn($s) => $s['items'] ?? [])
->groupBy('name')
->map->count()
->sortDesc()
->take(10);Email Timing Best Practices (2026)
| Step | Timing | Angle | Expected Open Rate |
|---|---|---|---|
| 1 | 1 hour | Helpful — "your cart is saved" | 40–50% |
| 2 | 24 hours | Social proof — "1,200 others viewed this today" | 25–35% |
| 3 | 72 hours | Urgency — "only 3 left in stock" | 15–25% |
- Send from a real inbox, not
no-reply@ - Include a product image thumbnail
- Single-click restore — never ask the customer to log in before their cart loads
- Add an unsubscribe link for GDPR / CAN-SPAM compliance
Continue the Series
- Previous: Real-Time Shopping Cart Count with Laravel Reverb
- Next: Shopping Cart Database Schema Design
- PHP Shopping Cart Session Management Best Practices — the persistence layer that makes this recovery system work
- Merging Guest and User Carts on Login
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.



