<?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;
}
}
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class DailySalesReport extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(public array $summary)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: "Daily Sales Report — {$this->summary['date']}",
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.reports.daily-sales',
with: [
'summary' => $this->summary,
],
);
}
}
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule): void
{
$schedule->command('reports:daily-sales')
->timezone('America/New_York')
->dailyAt('06:00')
->onOneServer()
->withoutOverlapping(30)
->emailOutputOnFailure(config('reports.ops_alert'))
->appendOutputTo(storage_path('logs/daily-sales.log'));
}
protected function commands(): void
{
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}
}
<x-mail::message>
# Daily Sales Report
**{{ \Illuminate\Support\Carbon::parse($summary['date'])->format('l, F j, Y') }}**
<x-mail::table>
| Metric | Value |
|:--------------|:--------------------------------------------------------|
| Orders | {{ number_format($summary['order_count']) }} |
| Gross revenue | ${{ number_format($summary['gross_revenue'] / 100, 2) }}|
| Refunds | ${{ number_format($summary['refund_total'] / 100, 2) }} |
| Net revenue | ${{ number_format($summary['net_revenue'] / 100, 2) }} |
</x-mail::table>
@if ($summary['top_products']->isNotEmpty())
## Top Products
@foreach ($summary['top_products'] as $product)
- {{ $product->product_name }} — {{ number_format($product->units) }} units
@endforeach
@else
_No orders were placed on this day._
@endif
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
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
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
class ReportQuery
SQL = <<~SQL.freeze
SELECT date_trunc('day', events.created_at) AS day,
count(*) AS total,
count(*) FILTER (WHERE events.kind = 'purchase') AS purchases
FROM events
Safe Raw SQL with exec_query + Binds
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Laravel database migrations for schema management
type AsyncTask = () => Promise<void>;
export interface GuardOptions {
name: string;
logger?: Pick<Console, "info" | "warn" | "error">;
}
Cron scheduling with node-cron (with guard)
package com.example.myapp.utils
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.PendingIntent
Notification channels and categories
Share this code
Here's the card — post it anywhere.