php erb 144 lines · 4 tabs

Nightly Sales Summary Report as a Scheduled Laravel Command with Mailable

Shared by codesnips Jul 2026
4 tabs
<?php

namespace App\Console\Commands;

use App\Mail\DailySalesReport;
use App\Models\Order;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;

class SendDailySalesReport extends Command
{
    protected $signature = 'reports:daily-sales {--date= : The day to report on (Y-m-d), defaults to yesterday}';

    protected $description = 'Aggregate a day of sales and email a summary to the reports recipients.';

    public function handle(): int
    {
        $date = $this->option('date')
            ? Carbon::parse($this->option('date'))->startOfDay()
            : Carbon::yesterday();

        $range = [$date->copy()->startOfDay(), $date->copy()->endOfDay()];

        $totals = Order::whereBetween('created_at', $range)
            ->selectRaw('COUNT(*) as order_count')
            ->selectRaw('COALESCE(SUM(total_cents), 0) as gross_revenue')
            ->selectRaw('COALESCE(SUM(refund_cents), 0) as refund_total')
            ->first();

        $topProducts = Order::whereBetween('orders.created_at', $range)
            ->join('order_items', 'order_items.order_id', '=', 'orders.id')
            ->selectRaw('order_items.product_name, SUM(order_items.quantity) as units')
            ->groupBy('order_items.product_name')
            ->orderByDesc('units')
            ->limit(5)
            ->get();

        $summary = [
            'date' => $date->toDateString(),
            'order_count' => (int) $totals->order_count,
            'gross_revenue' => (int) $totals->gross_revenue,
            'refund_total' => (int) $totals->refund_total,
            'net_revenue' => (int) $totals->gross_revenue - (int) $totals->refund_total,
            'top_products' => $topProducts,
        ];

        Mail::to(config('reports.recipients'))
            ->send(new DailySalesReport($summary));

        $this->info("Daily sales report for {$summary['date']} queued ({$summary['order_count']} orders).");

        return Command::SUCCESS;
    }
}
4 files · php, erb Explain with highlit

This snippet shows the full path a nightly report takes in Laravel: a console command aggregates yesterday's data, a Mailable renders it, and the scheduler runs it unattended. The pattern separates what to compute (SendDailySalesReport) from how to present it (DailySalesReport mailable) and when to run it (Console\Kernel), which keeps each piece testable in isolation.

In SendDailySalesReport, the command defines a $signature with an optional --date= option so it can be run for arbitrary days from the CLI, not just "yesterday". handle() resolves the target date via Carbon, defaulting to yesterday(), then builds a single aggregate query with selectRaw to pull order_count, gross_revenue, and refund_total in one round trip instead of N queries. Grouping the top products is done with a second bounded query using limit(5) so the email never balloons. The command returns Command::SUCCESS and writes an $this->info(...) line so the scheduler's output and any log tailing show a clear result.

A subtle but important detail: when there are zero orders the command still sends a report rather than silently skipping, because a missing email is ambiguous — recipients can't tell if sales were zero or the job failed. The recipients list is read from config('reports.recipients') so environments differ without code changes.

DailySalesReport mailable implements ShouldQueue, so the actual SMTP work happens on a queue worker and the scheduled process finishes fast. Its envelope() sets a dated subject and content() binds the view with the pre-computed $summary array; keeping formatting (currency, percentages) in the Blade view rather than the command keeps the data payload clean.

Console\Kernel wires it together with $schedule->command(...)->dailyAt('06:00'). The ->timezone(...), ->onOneServer(), and ->withoutOverlapping() calls matter in production: onOneServer prevents duplicate emails when multiple app servers share one cron, and withoutOverlapping guards against a slow run colliding with the next tick. emailOutputOnFailure surfaces breakages. The trade-off of ShouldQueue is that a stalled worker delays delivery, so teams often monitor queue latency alongside the schedule itself.


Related snips

Share this code

Here's the card — post it anywhere.

Nightly Sales Summary Report as a Scheduled Laravel Command with Mailable — share card
Link copied