Hingga bab ini kita memakai artisan sebagai konsumen: migrate, route:list, make:model. Kekuatan sesungguhnya muncul ketika kita menulis command sendiri — laporan harian otomatis, sinkronisasi data, pembersihan tabel, sampai task maintenance yang dijadwalkan. Command adalah pintu masuk paling ringan ke logika aplikasi: tidak ada HTTP, tidak ada middleware, cukup terminal dan PHP. Di chapter ini kita membangun report:daily yang membaca tabel posts, mencetak ringkasan rapi, lalu mengirim email — lengkap dengan argument, option, interaksi pengguna, dan kode keluar yang bisa diuji.
Membuat Command Baru #
Generator make:command menyiapkan kerangkanya. Kita mulai dari command laporan harian:
php artisan make:command SendDailyReportOutput yang Dihasilkan:
INFO Console command [app/Console/Commands/SendDailyReport.php] created successfully.Isi berkas yang dihasilkan Laravel 13 terlihat seperti ini:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('app:send-daily-report')]
#[Description('Command description')]
class SendDailyReport extends Command
{
/**
* Execute the console command.
*/
public function handle()
{
//
}
}Ada dua gaya mendefinisikan identitas command. Gaya baru Laravel 13 memakai attribute #[Signature] dan #[Description] seperti di atas; gaya klasik memakai property $signature dan $description di dalam kelas. Kedua gaya didukung dengan sintaks identik — contoh di bawah memakai property karena paling umum dijumpai di kode dan dokumentasi.
Anatomi Signature #
Nama command, argument, dan option semuanya didefinisikan dalam satu string signature. Berikut seluruh pola yang perlu kita ketahui:
| Sintaks | Arti | Contoh pemanggilan |
|---|---|---|
report:daily {user} | Argument wajib | php artisan report:daily [email protected] |
report:daily {user?} | Argument opsional | php artisan report:daily |
report:daily {[email protected]} | Argument dengan nilai default | php artisan report:daily |
report:daily {user*} | Argument array | php artisan report:daily a b c |
report:daily {--queue} | Option boolean (flag) | php artisan report:daily --queue |
report:daily {--days=7} | Option dengan nilai default | php artisan report:daily --days=30 |
Teks setelah tanda : pada tiap deklarasi juga berfungsi sebagai deskripsi yang tampil di php artisan help report:daily.
Command Lengkap #
Kita isi app/Console/Commands/SendDailyReport.php dengan logika sesungguhnya. Command ini merangkum postingan terbaru dari tabel posts (kolom user_id, title, views, published_at), menampilkannya sebagai tabel, lalu mengirim ringkasan email per penulis:
<?php
namespace App\Console\Commands;
use App\Models\Post;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendDailyReport extends Command
{
protected $signature = 'report:daily
{user? : Email penulis yang dilaporkan}
{--days=7 : Rentang hari laporan}
{--queue : Kirim email lewat queue}';
protected $description = 'Kirim ringkasan aktivitas postingan ke setiap penulis';
public function handle(): int
{
$email = $this->argument('user');
if ($email !== null) {
$user = User::where('email', $email)->first();
if ($user === null) {
$this->error("User dengan email {$email} tidak ditemukan.");
return self::FAILURE;
}
}
$days = (int) $this->option('days');
$posts = Post::query()
->whereBetween('published_at', [
now()->subDays($days)->startOfDay(), now(),
])
->when(isset($user), fn ($q) => $q->where('user_id', $user->id))
->with('user')
->orderByDesc('views')
->get();
$this->info("Ringkasan {$days} hari terakhir:");
$this->table(
['Penulis', 'Judul', 'Views'],
$posts->map(fn (Post $post) => [
$post->user->name, $post->title, $post->views,
]),
);
if (! $this->confirm('Kirim ringkasan ini via email?', true)) {
$this->line('Laporan dibatalkan.');
return self::SUCCESS;
}
$this->withProgressBar($posts->groupBy('user_id'), function ($authorPosts) use ($days): void {
$author = $authorPosts->first()->user;
Mail::raw(
"Halo {$author->name}, Anda menerbitkan "
.$authorPosts->count()." artikel dalam {$days} hari terakhir.",
fn ($message) => $message->to($author->email)->subject('Ringkasan Harian'),
);
});
$this->newLine();
$this->info('Ringkasan terkirim.');
return self::SUCCESS;
}
}Mari kita baca bagian-bagiannya. Signature dideklarasikan multi-baris supaya deskripsi tiap argument tetap terbaca. Method handle() menerima dependency lewat type hint seperti controller — di sini kita cukup memakai facade. Argument diambil dengan $this->argument('user'), option dengan $this->option('days'), dan flag boolean seperti --queue terbaca false bila tidak ditulis serta true bila ditulis — berguna ketika nanti kita mengganti Mail::raw() dengan Mailable yang dikirim lewat queue. Method $this->table() menerima header dan baris lalu mencetak tabel ASCII rapi. Method $this->confirm() mengajukan pertanyaan ya/tidak, dan $this->withProgressBar() menjalankan closure per item sambil menampilkan progress bar.
Berikut hasil menjalankannya dengan data dua penulis (tiga post Budi, dua post Siti):
php artisan report:daily --days=30Output yang Dihasilkan:
Ringkasan 30 hari terakhir:
+---------+-----------------------+-------+
| Penulis | Judul | Views |
+---------+-----------------------+-------+
| Budi | Belajar Laravel 13 | 320 |
| Budi | Rahasia Eloquent | 145 |
| Siti | Blade Components | 88 |
| Budi | Migration Tanpa Drama | 42 |
| Siti | Vite di Produksi | 15 |
+---------+-----------------------+-------+
Kirim ringkasan ini via email? (yes/no) [yes]:
>
Ringkasan terkirim.Progress bar menulis ke stderr dan digambar ulang satu baris penuh di terminal; saat dialirkan ke berkas, tiap keadaan tercetak terpisah:
0/2 [░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
2/2 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%Email di atas benar-benar terkirim ke MAIL_MAILER=log, terbukti dari isi storage/logs/laravel.log:
[2026-10-13 22:48:55] local.DEBUG: From: Laravel <[email protected]>
To: [email protected]
Subject: Ringkasan Harian
Halo Siti, Anda menerbitkan 2 artikel dalam 30 hari terakhir.Bertanya kepada Pengguna #
Selain confirm(), command memiliki $this->ask() untuk jawaban bebas, $this->anticipate() untuk jawaban dengan saran pelengkap otomatis, dan $this->choice() untuk pilihan terbatas. Command kecil report:tanya memakai ketiganya sekaligus:
<?php
// app/Console/Commands/TanyaPenerima.php — bagian inti
public function handle(): int
{
$email = $this->ask('Email penerima laporan');
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$this->error('Format email tidak valid.');
return self::INVALID;
}
$gaya = $this->anticipate('Gaya laporan', ['ringkas', 'lengkap', 'csv'], 'ringkas');
if ($this->confirm("Kirim laporan gaya {$gaya} ke {$email}?")) {
$this->info("Laporan gaya {$gaya} terkirim ke {$email}.");
return self::SUCCESS;
}
$this->error('Laporan dibatalkan.');
return self::INVALID;
}Command interaktif paling natural diuji lewat feature test yang memainkan tanya-jawabnya lewat expectsQuestion(), expectsConfirmation(), dan assertExitCode():
Output yang Dihasilkan:
PASS Tests\Feature\TanyaPenerimaTest
✓ laporan terkirim setelah tiga pertanyaan 0.14s
✓ email tidak valid mengembalikan kode invalid 0.01s
Tests: 2 passed (8 assertions)
Duration: 0.24sKode Keluar yang Berarti #
Nilai kembalian handle() menjadi exit code proses — penting saat command dipanggil dari cron, script shell, atau scheduler
:
| Konstanta | Exit code | Makna |
|---|---|---|
self::SUCCESS | 0 | Berhasil (default bila tidak ada return) |
self::FAILURE | 1 | Gagal — logika tidak selesai |
self::INVALID | 2 | Input tidak valid atau user membatalkan |
Ketiganya bekerja persis seperti tabel. Jalur FAILURE misalnya terlihat saat email tidak dikenal:
php artisan report:daily [email protected]Output yang Dihasilkan:
User dengan email [email protected] tidak ditemukan.Proses berhenti dengan exit code 1 — pesan $this->error() tampil bergaya merah di terminal. Sedangkan report:tanya yang dijawab no oleh pengguna keluar dengan kode 2, karena pembatalan adalah masalah input, bukan kegagalan sistem.
Registrasi Tanpa Konfigurasi #
Command di app/Console/Commands terdaftar otomatis: bootstrap/app.php memanggil Application::configure(), dan secara bawaan konfigurasi tersebut memindai folder itu. Closure command yang lebih pendek cukup ditulis di routes/console.php:
Artisan::command('report:hitung {--days=7}', function (int $days = 7) {
$jumlah = \App\Models\Post::whereBetween(
'published_at',
[now()->subDays($days)->startOfDay(), now()],
)->count();
$this->info("{$jumlah} postingan dalam {$days} hari terakhir.");
return 0;
})->purpose('Hitung postingan terbaru');Bukti ketiganya terdaftar tanpa registrasi manual:
php artisan list --raw | grep reportOutput yang Dihasilkan:
report:daily Kirim ringkasan aktivitas postingan ke setiap penulis
report:hitung Hitung postingan terbaru
report:tanya Tanya penerima laporan secara interaktifreport:daily kita kini hidup sebagai command yang bisa dijadwalkan, diuji, dan dipanggil dari mana saja. Sekuat apa pun command, ia tetap menerima data mentah dari argument — dan data mentah butuh validasi. Bab berikutnya membawa pola validasi
yang sudah kita kenal satu langkah lebih jauh: menulis rule sendiri.