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