Skip to content
TutorialLaravel

Laravel Cart Abandonment: Detect, Track and Recover Lost Sales with Automated Email Reminders

D

Dinesh Wijethunga

June 22, 2026Updated Aug 2, 2026 6 min readIntermediate 315 views
ShareX / TwitterLinkedIn
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>&1

Recovery 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)

StepTimingAngleExpected Open Rate
11 hourHelpful — "your cart is saved"40–50%
224 hoursSocial proof — "1,200 others viewed this today"25–35%
372 hoursUrgency — "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

D
Dinesh Wijethunga

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.

Comments(0)

Guest comments are held for moderation.

You might also like