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:

bash
php artisan make:command SendDailyReport

Output yang Dihasilkan:

text
   INFO  Console command [app/Console/Commands/SendDailyReport.php] created successfully.

Isi berkas yang dihasilkan Laravel 13 terlihat seperti ini:

php
<?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:

SintaksArtiContoh pemanggilan
report:daily {user}Argument wajibphp artisan report:daily [email protected]
report:daily {user?}Argument opsionalphp artisan report:daily
report:daily {[email protected]}Argument dengan nilai defaultphp artisan report:daily
report:daily {user*}Argument arrayphp artisan report:daily a b c
report:daily {--queue}Option boolean (flag)php artisan report:daily --queue
report:daily {--days=7}Option dengan nilai defaultphp 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
<?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):

bash
php artisan report:daily --days=30

Output yang Dihasilkan:

text
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:

text
 0/2 [░░░░░░░░░░░░░░░░░░░░░░░░░░░░]   0%
 2/2 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

Email di atas benar-benar terkirim ke MAIL_MAILER=log, terbukti dari isi storage/logs/laravel.log:

text
[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
<?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:

text
  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.24s

Kode Keluar yang Berarti #

Nilai kembalian handle() menjadi exit code proses — penting saat command dipanggil dari cron, script shell, atau scheduler :

KonstantaExit codeMakna
self::SUCCESS0Berhasil (default bila tidak ada return)
self::FAILURE1Gagal — logika tidak selesai
self::INVALID2Input tidak valid atau user membatalkan

Ketiganya bekerja persis seperti tabel. Jalur FAILURE misalnya terlihat saat email tidak dikenal:

bash
php artisan report:daily [email protected]

Output yang Dihasilkan:

text
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:

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:

bash
php artisan list --raw | grep report

Output yang Dihasilkan:

text
report:daily               Kirim ringkasan aktivitas postingan ke setiap penulis
report:hitung              Hitung postingan terbaru
report:tanya               Tanya penerima laporan secara interaktif

report: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.