Apa itu Task Schedule dan Cara Bikin di Projek Laravel 12 Website Sewa Mobil

Pelajari apa itu Task Scheduling di Laravel 12 dan bagaimana mengimplementasikannya pada website sewa mobil. Artikel ini membahas konsep cron job, Laravel Scheduler, berbagai use cases seperti auto-cancel expired bookings, reminder notifications, daily reports, late fee calculations, hingga deployment ke production server.


Bagian 1: Kenapa Butuh Task Scheduling?

Bayangkan kamu punya website sewa mobil dengan 50+ transaksi per hari. Setiap pagi, admin harus:

RUTINITAS MANUAL ADMIN:

08:00 - Cancel booking yang tidak dibayar 24 jam
09:00 - Kirim reminder ke customer yang pickup hari ini
10:00 - Cek mobil yang harusnya sudah dikembalikan
17:00 - Hitung denda keterlambatan
21:00 - Buat laporan harian untuk owner

TOTAL: 2-3 jam/hari untuk kerjaan repetitif!

Masalahnya:

  • Buang waktu — Admin bisa fokus ke hal lain
  • Human error — Lupa cancel, lupa reminder
  • Tidak scalable — Bagaimana kalau 500 transaksi/hari?

Solusi: Task Scheduling

Dengan Task Scheduling, semua task repetitif jalan otomatis:

AUTOMATED:

├── Setiap jam    → Auto-cancel expired bookings
├── Jam 18:00     → Kirim pickup reminder
├── Jam 08:00     → Kirim return reminder
├── Jam 23:00     → Hitung late fees
├── Jam 21:00     → Generate & kirim daily report
└── Tanggal 1     → Archive data lama

RESULT: 0 jam manual work, 100% consistent

Laravel menyediakan Task Scheduler yang powerful dan mudah digunakan. Mari kita pelajari.

Bagian 2: Apa itu Task Scheduling & Cron Job?

Cron Job: Fondasi Scheduling

Cron adalah scheduler bawaan Linux/Unix untuk menjalankan command pada waktu tertentu.

CRON SYNTAX:

┌───────────── menit (0-59)
│ ┌───────────── jam (0-23)
│ │ ┌───────────── tanggal (1-31)
│ │ │ ┌───────────── bulan (1-12)
│ │ │ │ ┌───────────── hari (0-6, Minggu=0)
│ │ │ │ │
* * * * * command

CONTOH:
─────────────────────────────────────────────────
* * * * *         → Setiap menit
0 * * * *         → Setiap jam (menit ke-0)
0 8 * * *         → Setiap hari jam 08:00
0 0 * * 0         → Setiap Minggu tengah malam
0 0 1 * *         → Setiap tanggal 1
*/5 * * * *       → Setiap 5 menit

Traditional Cron vs Laravel Scheduler

Traditional Cron:

# Harus tambah entry untuk SETIAP task
0 * * * * php /var/www/app/cancel-expired.php
0 8 * * * php /var/www/app/send-reminders.php
0 21 * * * php /var/www/app/daily-report.php
# Ribet, tidak version controlled, susah testing

Laravel Scheduler:

# Cuma SATU entry cron
* * * * * cd /var/www/sewamobil && php artisan schedule:run

Semua logic scheduling ada di dalam Laravel code — version controlled, testable, readable.

Bagaimana Laravel Scheduler Bekerja

┌─────────────────────────────────────────────┐
│           LARAVEL SCHEDULER FLOW            │
├─────────────────────────────────────────────┤
│                                             │
│  Cron: * * * * * php artisan schedule:run   │
│                    │                        │
│                    ▼                        │
│         ┌─────────────────────┐             │
│         │  Laravel Scheduler  │             │
│         │                     │             │
│         │  Cek jam sekarang   │             │
│         │  Task mana yang due?│             │
│         └─────────┬───────────┘             │
│                   │                         │
│         Execute tasks yang due              │
│                                             │
└─────────────────────────────────────────────┘

Setiap menit, Laravel check semua scheduled tasks. Yang waktunya sudah tiba, dijalankan. Yang belum, di-skip.

Bagian 3: Setup Laravel Scheduler

Lokasi Konfigurasi

Di Laravel 11+, scheduling dikonfigurasi di routes/console.php:

<?php
// routes/console.php

use Illuminate\\Support\\Facades\\Schedule;

Schedule::command('bookings:cancel-expired')->hourly();
Schedule::command('reminders:pickup')->dailyAt('18:00');
Schedule::command('reports:daily')->dailyAt('21:00');

Frequency Options

Laravel menyediakan fluent API yang readable:

// Menit
->everyMinute()
->everyFiveMinutes()
->everyTenMinutes()
->everyFifteenMinutes()
->everyThirtyMinutes()

// Jam
->hourly()
->hourlyAt(15)              // Setiap jam menit ke-15

// Harian
->daily()                   // Jam 00:00
->dailyAt('13:30')          // Jam 13:30
->twiceDaily(9, 17)         // Jam 09:00 dan 17:00

// Mingguan
->weekly()
->weeklyOn(1, '08:00')      // Senin jam 08:00

// Bulanan
->monthly()
->monthlyOn(1, '00:00')     // Tanggal 1 tengah malam

// Custom
->cron('0 8 * * 1-5')       // Jam 8 pagi, Senin-Jumat

Constraints

Tambahkan kondisi kapan task boleh jalan:

Schedule::command('reminders:pickup')
    ->dailyAt('18:00')
    ->weekdays()                    // Senin-Jumat saja
    ->timezone('Asia/Jakarta');

Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->environments(['production']); // Production only

Schedule::command('maintenance:check')
    ->daily()
    ->between('06:00', '22:00')     // Hanya jam 6 pagi - 10 malam
    ->skip(fn () => app()->isDownForMaintenance());

Testing Schedule

# Lihat semua scheduled tasks
php artisan schedule:list

# Jalankan scheduler sekali (untuk testing)
php artisan schedule:run

# Jalankan scheduler terus-menerus (local development)
php artisan schedule:work

# Test specific task
php artisan schedule:test

Output schedule:list:

+---------------------------------+-------------+-------------------+
| Command                         | Interval    | Next Due          |
+---------------------------------+-------------+-------------------+
| bookings:cancel-expired         | Every hour  | 2025-12-29 15:00  |
| reminders:pickup                | Daily 18:00 | 2025-12-29 18:00  |
| reports:daily                   | Daily 21:00 | 2025-12-29 21:00  |
+---------------------------------+-------------+-------------------+

Quick Start untuk Sewa Mobil

<?php
// routes/console.php

use Illuminate\\Support\\Facades\\Schedule;

// Cancel booking yang tidak dibayar dalam 24 jam
Schedule::command('bookings:cancel-expired')
    ->hourly()
    ->withoutOverlapping();

// Reminder pickup H-1
Schedule::command('reminders:pickup')
    ->dailyAt('18:00');

// Reminder return
Schedule::command('reminders:return')
    ->dailyAt('08:00');

// Hitung denda keterlambatan
Schedule::command('rentals:calculate-late-fees')
    ->dailyAt('23:00');

// Daily report ke owner
Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->environments(['production']);

Selanjutnya, kita akan buat command untuk masing-masing task.

Bagian 4: Use Case #1 — Auto-Cancel Expired Bookings

Problem

Customer booking mobil tapi tidak bayar dalam 24 jam. Akibatnya:

  • Mobil "terkunci" — tidak bisa di-book orang lain
  • Potential revenue hilang
  • Admin harus manual cancel satu per satu

Solution: Automated Cancellation

Buat command yang jalan setiap jam untuk cancel booking expired.

Step 1: Buat Artisan Command

php artisan make:command CancelExpiredBookings

<?php
// app/Console/Commands/CancelExpiredBookings.php

namespace App\\Console\\Commands;

use App\\Models\\Booking;
use App\\Notifications\\BookingExpiredNotification;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;
use Illuminate\\Support\\Facades\\Log;

class CancelExpiredBookings extends Command
{
    protected $signature = 'bookings:cancel-expired
                            {--dry-run : Lihat tanpa execute}';

    protected $description = 'Cancel bookings yang tidak dibayar dalam 24 jam';

    public function handle()
    {
        $expiredBookings = Booking::where('status', 'pending')
            ->where('payment_deadline', '<', now())
            ->with(['user', 'car'])
            ->get();

        if ($expiredBookings->isEmpty()) {
            $this->info('Tidak ada booking expired.');
            return Command::SUCCESS;
        }

        $this->info("Ditemukan {$expiredBookings->count()} booking expired.");

        if ($this->option('dry-run')) {
            $this->table(
                ['ID', 'Booking Code', 'Customer', 'Car', 'Deadline'],
                $expiredBookings->map(fn ($b) => [
                    $b->id,
                    $b->booking_code,
                    $b->user->name,
                    $b->car->name,
                    $b->payment_deadline->format('d M Y H:i'),
                ])
            );
            $this->warn('Dry run mode - tidak ada perubahan.');
            return Command::SUCCESS;
        }

        $cancelled = 0;

        DB::transaction(function () use ($expiredBookings, &$cancelled) {
            foreach ($expiredBookings as $booking) {
                // Update status
                $booking->update([
                    'status' => 'expired',
                    'expired_at' => now(),
                ]);

                // Release car availability
                $booking->car->update(['is_available' => true]);

                // Notify customer
                $booking->user->notify(new BookingExpiredNotification($booking));

                // Log untuk audit
                Log::channel('scheduler')->info('Booking expired', [
                    'booking_code' => $booking->booking_code,
                    'user_id' => $booking->user_id,
                    'car_id' => $booking->car_id,
                ]);

                $cancelled++;
                $this->line("✓ Cancelled: {$booking->booking_code}");
            }
        });

        $this->info("Selesai! {$cancelled} bookings cancelled.");

        return Command::SUCCESS;
    }
}

Step 2: Buat Notification

<?php
// app/Notifications/BookingExpiredNotification.php

namespace App\\Notifications;

use App\\Models\\Booking;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Notifications\\Messages\\MailMessage;
use Illuminate\\Notifications\\Notification;

class BookingExpiredNotification extends Notification
{
    use Queueable;

    public function __construct(public Booking $booking) {}

    public function via($notifiable): array
    {
        return ['mail', 'database'];
    }

    public function toMail($notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Booking Anda Telah Expired')
            ->greeting("Halo {$notifiable->name},")
            ->line("Booking Anda untuk {$this->booking->car->name} telah expired karena tidak ada pembayaran dalam 24 jam.")
            ->line("Kode Booking: {$this->booking->booking_code}")
            ->action('Booking Ulang', url('/cars/' . $this->booking->car_id))
            ->line('Silakan booking ulang jika masih berminat.');
    }

    public function toArray($notifiable): array
    {
        return [
            'type' => 'booking_expired',
            'booking_id' => $this->booking->id,
            'booking_code' => $this->booking->booking_code,
            'message' => "Booking {$this->booking->booking_code} telah expired",
        ];
    }
}

Step 3: Register Schedule

// routes/console.php

Schedule::command('bookings:cancel-expired')
    ->hourly()
    ->withoutOverlapping()          // Prevent duplicate runs
    ->onOneServer()                 // Untuk multi-server
    ->appendOutputTo(storage_path('logs/scheduler.log'))
    ->emailOutputOnFailure('[email protected]');

Step 4: Testing

# Dry run - lihat apa yang akan di-cancel
php artisan bookings:cancel-expired --dry-run

# Execute
php artisan bookings:cancel-expired

# Test via scheduler
php artisan schedule:test --name="bookings:cancel-expired"

Database Schema Reference

// Migration: bookings table
Schema::create('bookings', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->foreignId('car_id')->constrained();
    $table->string('booking_code')->unique();
    $table->enum('status', ['pending', 'confirmed', 'active', 'completed', 'cancelled', 'expired']);
    $table->timestamp('payment_deadline');
    $table->timestamp('pickup_date');
    $table->timestamp('return_date');
    $table->decimal('total_price', 12, 2);
    $table->timestamp('expired_at')->nullable();
    $table->timestamps();
});

Dengan command ini, tidak ada lagi booking yang "nyangkut" — semua expired bookings otomatis di-cancel setiap jam.

Bagian 5: Use Case #2 & #3 — Reminders & Late Fees

Use Case #2: Pickup Reminder

Customer yang sudah booking sering lupa jadwal pickup. Kirim reminder otomatis H-1 jam 18:00.

Command: SendPickupReminders

php artisan make:command SendPickupReminders

<?php
// app/Console/Commands/SendPickupReminders.php

namespace App\\Console\\Commands;

use App\\Models\\Booking;
use App\\Notifications\\PickupReminderNotification;
use Illuminate\\Console\\Command;

class SendPickupReminders extends Command
{
    protected $signature = 'reminders:pickup';
    protected $description = 'Kirim reminder pickup untuk booking besok';

    public function handle()
    {
        $tomorrow = now()->addDay()->toDateString();

        $bookings = Booking::where('status', 'confirmed')
            ->whereDate('pickup_date', $tomorrow)
            ->with(['user', 'car'])
            ->get();

        if ($bookings->isEmpty()) {
            $this->info('Tidak ada pickup besok.');
            return Command::SUCCESS;
        }

        $this->info("Mengirim {$bookings->count()} reminder...");

        foreach ($bookings as $booking) {
            $booking->user->notify(new PickupReminderNotification($booking));
            $this->line("✓ Reminder sent: {$booking->user->name} - {$booking->car->name}");
        }

        $this->info('Selesai!');
        return Command::SUCCESS;
    }
}

Notification

<?php
// app/Notifications/PickupReminderNotification.php

namespace App\\Notifications;

use App\\Models\\Booking;
use Illuminate\\Notifications\\Messages\\MailMessage;
use Illuminate\\Notifications\\Notification;

class PickupReminderNotification extends Notification
{
    public function __construct(public Booking $booking) {}

    public function via($notifiable): array
    {
        return ['mail', 'database'];
    }

    public function toMail($notifiable): MailMessage
    {
        $pickup = $this->booking->pickup_date->format('d M Y, H:i');

        return (new MailMessage)
            ->subject('Reminder: Pickup Mobil Besok')
            ->greeting("Halo {$notifiable->name}!")
            ->line("Jangan lupa, Anda memiliki jadwal pickup besok:")
            ->line("🚗 Mobil: {$this->booking->car->name}")
            ->line("📅 Waktu: {$pickup}")
            ->line("📍 Lokasi: {$this->booking->car->pickup_location}")
            ->line("📋 Dokumen yang diperlukan: KTP, SIM A")
            ->action('Lihat Detail Booking', url('/bookings/' . $this->booking->id));
    }
}

Schedule

// routes/console.php

Schedule::command('reminders:pickup')
    ->dailyAt('18:00')
    ->weekdays()
    ->timezone('Asia/Jakarta');


Use Case #3: Return Reminder & Late Fee Calculation

Mobil yang harusnya dikembalikan hari ini perlu di-remind. Dan jika telat, hitung dendanya.

Command #1: SendReturnReminders (Pagi)

<?php
// app/Console/Commands/SendReturnReminders.php

namespace App\\Console\\Commands;

use App\\Models\\Booking;
use App\\Notifications\\ReturnReminderNotification;
use Illuminate\\Console\\Command;

class SendReturnReminders extends Command
{
    protected $signature = 'reminders:return';
    protected $description = 'Kirim reminder return untuk booking yang due hari ini';

    public function handle()
    {
        $today = now()->toDateString();

        $bookings = Booking::where('status', 'active')
            ->whereDate('return_date', $today)
            ->with(['user', 'car'])
            ->get();

        if ($bookings->isEmpty()) {
            $this->info('Tidak ada return hari ini.');
            return Command::SUCCESS;
        }

        foreach ($bookings as $booking) {
            $booking->user->notify(new ReturnReminderNotification($booking));
            $this->line("✓ {$booking->user->name} - return jam {$booking->return_date->format('H:i')}");
        }

        $this->info("Sent {$bookings->count()} reminders.");
        return Command::SUCCESS;
    }
}

Command #2: CalculateLateFees (Malam)

<?php
// app/Console/Commands/CalculateLateFees.php

namespace App\\Console\\Commands;

use App\\Models\\Booking;
use App\\Models\\LateFee;
use App\\Notifications\\LateFeeNotification;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;

class CalculateLateFees extends Command
{
    protected $signature = 'rentals:calculate-late-fees';
    protected $description = 'Hitung denda untuk rental yang telat return';

    // Late fee rules
    private const GRACE_PERIOD_HOURS = 2;
    private const RATES = [
        ['max_hours' => 6, 'percentage' => 25],   // 2-6 jam: 25%
        ['max_hours' => 12, 'percentage' => 50],  // 6-12 jam: 50%
        ['max_hours' => 24, 'percentage' => 100], // 12-24 jam: 100%
    ];
    private const MAX_DAYS_MULTIPLIER = 3; // Max 3x harga harian

    public function handle()
    {
        // Cari rental yang harusnya sudah return tapi masih active
        $lateBookings = Booking::where('status', 'active')
            ->where('return_date', '<', now()->subHours(self::GRACE_PERIOD_HOURS))
            ->whereDoesntHave('lateFees', function ($q) {
                $q->whereDate('created_at', today());
            })
            ->with(['user', 'car'])
            ->get();

        if ($lateBookings->isEmpty()) {
            $this->info('Tidak ada rental yang telat.');
            return Command::SUCCESS;
        }

        $this->info("Ditemukan {$lateBookings->count()} rental telat.");

        DB::transaction(function () use ($lateBookings) {
            foreach ($lateBookings as $booking) {
                $fee = $this->calculateFee($booking);

                // Simpan late fee
                $lateFee = LateFee::create([
                    'booking_id' => $booking->id,
                    'hours_late' => $fee['hours'],
                    'daily_rate' => $booking->daily_rate,
                    'fee_amount' => $fee['amount'],
                    'calculation_note' => $fee['note'],
                ]);

                // Notify customer
                $booking->user->notify(new LateFeeNotification($booking, $lateFee));

                $this->warn("⚠ {$booking->booking_code}: {$fee['hours']} jam telat = Rp " . number_format($fee['amount']));
            }
        });

        return Command::SUCCESS;
    }

    private function calculateFee(Booking $booking): array
    {
        $hoursLate = now()->diffInHours($booking->return_date);
        $dailyRate = $booking->daily_rate;

        // Kurangi grace period
        $billableHours = max(0, $hoursLate - self::GRACE_PERIOD_HOURS);

        if ($billableHours <= 0) {
            return ['hours' => $hoursLate, 'amount' => 0, 'note' => 'Within grace period'];
        }

        // Hitung berdasarkan tier
        $percentage = 100; // Default full day
        $note = '';

        foreach (self::RATES as $rate) {
            if ($billableHours <= $rate['max_hours']) {
                $percentage = $rate['percentage'];
                $note = "{$billableHours} hours = {$percentage}% of daily rate";
                break;
            }
        }

        // Jika lebih dari 24 jam, hitung per hari
        if ($billableHours > 24) {
            $days = ceil($billableHours / 24);
            $days = min($days, self::MAX_DAYS_MULTIPLIER); // Cap at 3 days
            $amount = $dailyRate * $days;
            $note = "{$billableHours} hours = {$days} days (capped at " . self::MAX_DAYS_MULTIPLIER . ")";
        } else {
            $amount = ($dailyRate * $percentage) / 100;
        }

        return [
            'hours' => $hoursLate,
            'amount' => $amount,
            'note' => $note,
        ];
    }
}

Late Fees Migration

Schema::create('late_fees', function (Blueprint $table) {
    $table->id();
    $table->foreignId('booking_id')->constrained();
    $table->integer('hours_late');
    $table->decimal('daily_rate', 12, 2);
    $table->decimal('fee_amount', 12, 2);
    $table->string('calculation_note')->nullable();
    $table->enum('status', ['pending', 'paid', 'waived'])->default('pending');
    $table->timestamps();
});

Schedule

// routes/console.php

// Pagi: Reminder return
Schedule::command('reminders:return')
    ->dailyAt('08:00')
    ->timezone('Asia/Jakarta');

// Malam: Hitung denda
Schedule::command('rentals:calculate-late-fees')
    ->dailyAt('23:00')
    ->timezone('Asia/Jakarta');

Dengan dua command ini, customer dapat reminder tepat waktu, dan denda dihitung otomatis untuk yang telat.

Bagian 6: Use Case #4 & #5 — Reports & Maintenance

Use Case #4: Daily Revenue Report

Owner butuh summary harian: berapa transaksi, berapa revenue, mobil mana yang paling laku. Kirim otomatis setiap jam 21:00.

Command: GenerateDailyReport

php artisan make:command GenerateDailyReport

<?php
// app/Console/Commands/GenerateDailyReport.php

namespace App\\Console\\Commands;

use App\\Models\\Booking;
use App\\Models\\LateFee;
use App\\Mail\\DailyReportMail;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Mail;
use Barryvdh\\DomPDF\\Facade\\Pdf;

class GenerateDailyReport extends Command
{
    protected $signature = 'reports:daily {--date= : Tanggal report (default: hari ini)}';
    protected $description = 'Generate dan kirim daily revenue report';

    public function handle()
    {
        $date = $this->option('date')
            ? \\Carbon\\Carbon::parse($this->option('date'))
            : today();

        $this->info("Generating report untuk {$date->format('d M Y')}...");

        // Gather data
        $report = $this->gatherReportData($date);

        // Generate PDF
        $pdf = $this->generatePdf($report, $date);

        // Send email
        $this->sendReport($pdf, $report, $date);

        $this->info('Report sent successfully!');
        return Command::SUCCESS;
    }

    private function gatherReportData($date): array
    {
        // Booking statistics
        $bookings = Booking::whereDate('created_at', $date)->get();

        $stats = [
            'total_bookings' => $bookings->count(),
            'confirmed' => $bookings->where('status', 'confirmed')->count(),
            'completed' => $bookings->where('status', 'completed')->count(),
            'cancelled' => $bookings->where('status', 'cancelled')->count(),
            'expired' => $bookings->where('status', 'expired')->count(),
        ];

        // Revenue
        $completedBookings = Booking::whereDate('updated_at', $date)
            ->where('status', 'completed')
            ->get();

        $lateFees = LateFee::whereDate('created_at', $date)
            ->where('status', 'paid')
            ->sum('fee_amount');

        $revenue = [
            'bookings' => $completedBookings->sum('total_price'),
            'late_fees' => $lateFees,
            'total' => $completedBookings->sum('total_price') + $lateFees,
        ];

        // Top cars
        $topCars = Booking::whereDate('created_at', $date)
            ->select('car_id', \\DB::raw('COUNT(*) as total'))
            ->groupBy('car_id')
            ->orderByDesc('total')
            ->limit(5)
            ->with('car:id,name,brand')
            ->get()
            ->map(fn ($item) => [
                'name' => $item->car->brand . ' ' . $item->car->name,
                'rentals' => $item->total,
            ]);

        // Comparison with yesterday
        $yesterdayRevenue = Booking::whereDate('updated_at', $date->copy()->subDay())
            ->where('status', 'completed')
            ->sum('total_price');

        $comparison = $yesterdayRevenue > 0
            ? round((($revenue['bookings'] - $yesterdayRevenue) / $yesterdayRevenue) * 100, 1)
            : 0;

        return [
            'stats' => $stats,
            'revenue' => $revenue,
            'top_cars' => $topCars,
            'comparison' => $comparison,
        ];
    }

    private function generatePdf(array $report, $date): string
    {
        $pdf = Pdf::loadView('reports.daily', [
            'report' => $report,
            'date' => $date,
        ]);

        $filename = "daily-report-{$date->format('Y-m-d')}.pdf";
        $path = storage_path("app/reports/{$filename}");

        $pdf->save($path);

        return $path;
    }

    private function sendReport(string $pdfPath, array $report, $date): void
    {
        $recipients = config('reports.daily_recipients', ['[email protected]']);

        Mail::to($recipients)->send(new DailyReportMail($report, $date, $pdfPath));
    }
}

Report Email View

<?php
// app/Mail/DailyReportMail.php

namespace App\\Mail;

use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Attachment;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;

class DailyReportMail extends Mailable
{
    public function __construct(
        public array $report,
        public $date,
        public string $pdfPath
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "Daily Report - {$this->date->format('d M Y')}",
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'emails.reports.daily',
        );
    }

    public function attachments(): array
    {
        return [
            Attachment::fromPath($this->pdfPath),
        ];
    }
}

{{-- resources/views/emails/reports/daily.blade.php --}}

<x-mail::message>
# Daily Report - {{ $date->format('d M Y') }}

## 📊 Summary

| Metric | Value |
|--------|-------|
| Total Bookings | {{ $report['stats']['total_bookings'] }} |
| Completed | {{ $report['stats']['completed'] }} |
| Cancelled | {{ $report['stats']['cancelled'] }} |

## 💰 Revenue

**Total: Rp {{ number_format($report['revenue']['total']) }}**

- Booking Revenue: Rp {{ number_format($report['revenue']['bookings']) }}
- Late Fees: Rp {{ number_format($report['revenue']['late_fees']) }}

@if($report['comparison'] != 0)
**{{ $report['comparison'] > 0 ? '📈' : '📉' }} {{ abs($report['comparison']) }}% vs kemarin**
@endif

## 🚗 Top Cars

@foreach($report['top_cars'] as $car)
- {{ $car['name'] }} ({{ $car['rentals'] }} rentals)
@endforeach

<x-mail::button :url="config('app.url') . '/admin/reports'">
Lihat Detail di Dashboard
</x-mail::button>

</x-mail::message>

Schedule

Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->timezone('Asia/Jakarta')
    ->environments(['production'])
    ->emailOutputOnFailure('[email protected]');


Use Case #5: Car Maintenance Scheduler

Mobil perlu service berkala. Track kilometer dan alert jika mendekati jadwal service.

Command: CheckMaintenanceDue

<?php
// app/Console/Commands/CheckMaintenanceDue.php

namespace App\\Console\\Commands;

use App\\Models\\Car;
use App\\Notifications\\MaintenanceDueNotification;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Notification;

class CheckMaintenanceDue extends Command
{
    protected $signature = 'cars:check-maintenance';
    protected $description = 'Check mobil yang perlu maintenance';

    private const KM_THRESHOLD = 500;      // Alert 500km sebelum due
    private const DAYS_THRESHOLD = 7;      // Alert 7 hari sebelum due

    public function handle()
    {
        $this->info('Checking maintenance schedules...');

        // Check by kilometer
        $kmDue = Car::where('is_active', true)
            ->whereRaw('current_km >= (next_service_km - ?)', [self::KM_THRESHOLD])
            ->get();

        // Check by date
        $dateDue = Car::where('is_active', true)
            ->whereDate('next_service_date', '<=', now()->addDays(self::DAYS_THRESHOLD))
            ->get();

        $allDue = $kmDue->merge($dateDue)->unique('id');

        if ($allDue->isEmpty()) {
            $this->info('Semua mobil dalam kondisi baik.');
            return Command::SUCCESS;
        }

        $this->warn("Ditemukan {$allDue->count()} mobil perlu maintenance:");

        foreach ($allDue as $car) {
            $reason = $this->getMaintenanceReason($car);
            $this->line("⚠ {$car->plate_number} - {$car->name}: {$reason}");

            // Optional: Auto-set car as unavailable
            if ($this->shouldAutoDisable($car)) {
                $car->update(['is_available' => false, 'status' => 'maintenance']);
                $this->error("  → Auto-disabled for maintenance");
            }
        }

        // Notify fleet manager
        Notification::route('mail', config('fleet.manager_email'))
            ->notify(new MaintenanceDueNotification($allDue));

        $this->info('Fleet manager notified.');
        return Command::SUCCESS;
    }

    private function getMaintenanceReason(Car $car): string
    {
        $reasons = [];

        if ($car->current_km >= ($car->next_service_km - self::KM_THRESHOLD)) {
            $remaining = $car->next_service_km - $car->current_km;
            $reasons[] = "KM ({$remaining}km remaining)";
        }

        if ($car->next_service_date <= now()->addDays(self::DAYS_THRESHOLD)) {
            $days = now()->diffInDays($car->next_service_date, false);
            $reasons[] = "Date ({$days} days)";
        }

        return implode(', ', $reasons);
    }

    private function shouldAutoDisable(Car $car): bool
    {
        // Auto-disable jika sudah melewati batas
        return $car->current_km >= $car->next_service_km
            || $car->next_service_date <= now();
    }
}

Cars Table Addition

// Migration tambahan untuk cars table
$table->integer('current_km')->default(0);
$table->integer('next_service_km')->nullable();
$table->date('next_service_date')->nullable();
$table->date('last_service_date')->nullable();

Update Kilometer After Rental

// Di mana pun rental completed
public function completeRental(Booking $booking)
{
    $booking->update(['status' => 'completed']);

    // Update car kilometer
    $booking->car->increment('current_km', $booking->distance_traveled);
}

Schedule

Schedule::command('cars:check-maintenance')
    ->dailyAt('06:00')
    ->timezone('Asia/Jakarta');


Bonus: Data Cleanup

// Archive old completed bookings (> 2 tahun)
Schedule::command('model:prune', ['--model' => \\App\\Models\\Booking::class])
    ->monthlyOn(1, '03:00');

// Atau custom command
Schedule::command('data:archive-old-bookings')
    ->monthlyOn(1, '03:00')
    ->environments(['production']);

// Cleanup temp files
Schedule::command('telescope:prune --hours=48')
    ->daily();

Schedule::command('activitylog:clean --days=90')
    ->weekly();

Dengan scheduled reports dan maintenance checks, owner mendapat insights harian dan fleet tetap terawat tanpa manual monitoring.

Bagian 7: Production Deployment

Scheduler sudah siap di local. Sekarang deploy ke production server.

Step 1: Setup Cron di Server

SSH ke server dan edit crontab:

# Login ke server
ssh [email protected]

# Edit crontab
crontab -e

# Tambahkan SATU entry ini saja
* * * * * cd /var/www/sewamobil && php artisan schedule:run >> /dev/null 2>&1

Cron ini jalan setiap menit, dan Laravel yang tentukan task mana yang perlu dieksekusi.

Dengan logging:

* * * * * cd /var/www/sewamobil && php artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1

Verifikasi cron aktif:

crontab -l

Step 2: Multi-Server Setup

Jika punya multiple servers di belakang load balancer, pastikan task hanya jalan di SATU server:

Schedule::command('bookings:cancel-expired')
    ->hourly()
    ->onOneServer();  // ← Penting!

Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->onOneServer();

Requirement: Cache driver harus Redis/Memcached/Database (bukan file).

# .env
CACHE_DRIVER=redis

Step 3: Prevent Overlapping

Task yang lama jangan sampai jalan dobel:

Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->withoutOverlapping()      // Skip jika masih running
    ->withoutOverlapping(30);   // Lock expire setelah 30 menit

Step 4: Queue Integration

Untuk task yang berat, gunakan queue agar tidak blocking:

// Dispatch job instead of running sync
Schedule::job(new GenerateDailyReportJob)
    ->dailyAt('21:00');

// Atau queue command
Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->runInBackground();  // Run di background process

Pastikan queue worker running:

# Supervisor config: /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/sewamobil/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=2
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/laravel-worker.log

Step 5: Environment-Specific Scheduling

Beberapa task hanya untuk production:

// Hanya di production
Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->environments(['production']);

// Skip di local
Schedule::command('data:archive-old')
    ->monthly()
    ->skip(fn () => app()->environment('local'));

// Berbeda waktu per environment
Schedule::command('reminders:pickup')
    ->dailyAt(app()->environment('production') ? '18:00' : '10:00');

Step 6: Timezone

Pastikan timezone benar:

// config/app.php
'timezone' => 'Asia/Jakarta',

// Atau per-task
Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->timezone('Asia/Jakarta');

Step 7: Maintenance Mode

By default, scheduled tasks tidak jalan saat maintenance mode. Untuk force run:

Schedule::command('bookings:cancel-expired')
    ->hourly()
    ->evenInMaintenanceMode();  // Tetap jalan saat maintenance

Complete Server Setup Checklist

# 1. Set timezone server
sudo timedatectl set-timezone Asia/Jakarta

# 2. Verify PHP path
which php
# /usr/bin/php

# 3. Setup cron
crontab -e
# * * * * * cd /var/www/sewamobil && php artisan schedule:run >> /dev/null 2>&1

# 4. Set proper permissions
sudo chown -R www-data:www-data /var/www/sewamobil/storage
sudo chmod -R 775 /var/www/sewamobil/storage

# 5. Test scheduler
cd /var/www/sewamobil
php artisan schedule:list
php artisan schedule:run

# 6. Monitor cron logs
tail -f /var/log/syslog | grep CRON

Deployment Script Integration

#!/bin/bash
# deploy.sh

# Pull latest code
git pull origin main

# Install dependencies
composer install --no-dev --optimize-autoloader

# Clear caches
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Run migrations
php artisan migrate --force

# Restart queue workers (jika pakai supervisor)
sudo supervisorctl restart laravel-worker:*

# Scheduler otomatis pakai config baru di next run
echo "Deployment complete!"

Server siap! Scheduler akan jalan otomatis setiap menit.

Bagian 8: Monitoring, Error Handling & Kesimpulan

Error Handling

Tangkap error dan kirim alert ketika task gagal:

Schedule::command('bookings:cancel-expired')
    ->hourly()
    ->onFailure(function () {
        // Kirim alert ke Slack/Telegram
        \\Log::channel('slack')->error('Scheduler failed: bookings:cancel-expired');

        // Atau notify admin
        \\Notification::route('mail', '[email protected]')
            ->notify(new SchedulerFailedNotification('bookings:cancel-expired'));
    })
    ->onSuccess(function () {
        \\Log::info('bookings:cancel-expired completed');
    });

Output Handling

// Simpan output ke file
Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->sendOutputTo(storage_path('logs/daily-report.log'))      // Overwrite
    ->appendOutputTo(storage_path('logs/daily-report.log'));   // Append

// Email output jika error
Schedule::command('bookings:cancel-expired')
    ->hourly()
    ->emailOutputOnFailure('[email protected]');

// Email output selalu
->emailOutputTo('[email protected]');

Health Monitoring dengan Ping

Integrasikan dengan monitoring service (Healthchecks.io, Cronitor, etc):

Schedule::command('bookings:cancel-expired')
    ->hourly()
    ->pingOnSuccess('<https://hc-ping.com/your-uuid>')
    ->pingOnFailure('<https://hc-ping.com/your-uuid/fail>');

// Ping sebelum dan sesudah
->pingBefore('<https://hc-ping.com/your-uuid/start>')
->thenPing('<https://hc-ping.com/your-uuid>');

Logging Best Practices

Di dalam command, gunakan output methods:

public function handle()
{
    $this->info('Starting task...');           // Hijau
    $this->line('Processing item #1');          // Normal
    $this->warn('Warning: low stock');          // Kuning
    $this->error('Error occurred!');            // Merah

    // Progress bar untuk loop
    $items = Booking::pending()->get();
    $bar = $this->output->createProgressBar($items->count());

    foreach ($items as $item) {
        // process...
        $bar->advance();
    }

    $bar->finish();
    $this->newLine();
    $this->info('Done!');
}

Complete Schedule Configuration

<?php
// routes/console.php

use Illuminate\\Support\\Facades\\Schedule;

/*
|--------------------------------------------------------------------------
| Scheduled Tasks - Sewa Mobil
|--------------------------------------------------------------------------
*/

// Setiap jam: Cancel expired bookings
Schedule::command('bookings:cancel-expired')
    ->hourly()
    ->withoutOverlapping()
    ->onOneServer()
    ->appendOutputTo(storage_path('logs/scheduler.log'))
    ->emailOutputOnFailure('[email protected]');

// Jam 18:00: Pickup reminder (H-1)
Schedule::command('reminders:pickup')
    ->dailyAt('18:00')
    ->timezone('Asia/Jakarta')
    ->weekdays();

// Jam 08:00: Return reminder
Schedule::command('reminders:return')
    ->dailyAt('08:00')
    ->timezone('Asia/Jakarta');

// Jam 23:00: Calculate late fees
Schedule::command('rentals:calculate-late-fees')
    ->dailyAt('23:00')
    ->timezone('Asia/Jakarta')
    ->onOneServer();

// Jam 21:00: Daily report (production only)
Schedule::command('reports:daily')
    ->dailyAt('21:00')
    ->timezone('Asia/Jakarta')
    ->environments(['production'])
    ->onOneServer()
    ->emailOutputOnFailure('[email protected]');

// Jam 06:00: Check car maintenance
Schedule::command('cars:check-maintenance')
    ->dailyAt('06:00')
    ->timezone('Asia/Jakarta');

// Tanggal 1: Archive old data
Schedule::command('data:archive-old-bookings')
    ->monthlyOn(1, '03:00')
    ->timezone('Asia/Jakarta')
    ->environments(['production'])
    ->onOneServer();

// Weekly: Cleanup
Schedule::command('telescope:prune --hours=72')
    ->weekly()
    ->sundays()
    ->at('04:00');

Debugging Tips

# Lihat semua scheduled tasks
php artisan schedule:list

# Output:
# +---------------------------------+-------------+-------------+-------------------+
# | Command                         | Interval    | Description | Next Due          |
# +---------------------------------+-------------+-------------+-------------------+
# | bookings:cancel-expired         | 0 * * * *   | ...         | 2025-12-29 16:00  |
# | reminders:pickup                | 0 18 * * *  | ...         | 2025-12-29 18:00  |
# +---------------------------------+-------------+-------------+-------------------+

# Test specific command
php artisan schedule:test
# Pilih command dari list, akan langsung execute

# Jalankan scheduler sekali
php artisan schedule:run

# Development: jalankan terus
php artisan schedule:work


Summary

┌─────────────────────────────────────────────────────────────────┐
│                    TASK SCHEDULING SUMMARY                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  📌 KEY TAKEAWAYS:                                              │
│                                                                  │
│  1. Task Scheduling = automated recurring tasks                 │
│  2. Laravel Scheduler = readable, testable, version controlled  │
│  3. Satu cron entry untuk semua tasks                          │
│  4. Buat Artisan Command untuk setiap task                     │
│  5. withoutOverlapping() untuk prevent duplicate runs          │
│  6. onOneServer() untuk multi-server setup                     │
│  7. Monitor dengan ping services                                │
│  8. Always handle errors dan kirim alerts                      │
│                                                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  📋 USE CASES SEWA MOBIL:                                       │
│                                                                  │
│  ├── Hourly    : Auto-cancel expired bookings                  │
│  ├── 08:00     : Return reminders                               │
│  ├── 18:00     : Pickup reminders (H-1)                        │
│  ├── 21:00     : Daily revenue report                          │
│  ├── 23:00     : Late fee calculation                          │
│  ├── 06:00     : Car maintenance check                         │
│  └── Monthly   : Data archiving                                 │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘


Rekomendasi Kelas BuildWithAngga

Untuk memperdalam Laravel dan backend development:

Laravel Track:

  • Laravel Fundamentals — Dasar yang kuat
  • Laravel Queue & Jobs — Background processing
  • Laravel API Development — RESTful APIs
  • Build Complete Rental Platform — Project-based learning

Project-Based:

  • Build Car Rental Website — Mirip dengan artikel ini
  • Build Booking System — Reservation management
  • Build SaaS dengan Laravel — Multi-tenant apps

DevOps & Production:

  • Server Management untuk Laravel — VPS setup
  • Docker untuk Developer — Containerization
  • CI/CD Pipeline — Automated deployment

Kunjungi buildwithangga.com untuk explore semua kelas yang tersedia.


Closing

Task Scheduling adalah skill essential untuk backend developer. Dengan automation, kamu bisa:

  • Menghemat waktu — Tidak ada lagi kerjaan manual repetitif
  • Meningkatkan reliability — Tasks jalan konsisten, tidak tergantung manusia
  • Scale dengan mudah — 10 atau 10.000 transaksi, effort sama

Mulai dari yang simple: satu scheduled command untuk task yang paling sering dilakukan manual. Kemudian tambahkan lebih banyak seiring kebutuhan.

"Automate the boring stuff, focus on what matters."

Angga Risky Setiawan, Founder BuildWithAngga