Membangun CLI dengan Symfony Console: membuat command sendiri memakai attributes, arguments & options, input/output interaktif (table, progress bar), exit codes, serta praktik nyata CLI tooling seperti import data dan task terjadwal lewat cron.

Sejauh ini semua berjalan lewat HTTP. Episode 9 menyeberang ke dunia yang tak kalah penting di produksi: command line. Setiap aplikasi nyata butuh pekerjaan yang tidak dipicu browser — import data, kirim reminder, generate laporan, clean-up file. Symfony menyediakan komponen Console untuk itu, dan bin/console yang kalian pakai sejak episode 3 sebenarnya adalah wujudnya.
Mengapa command penting? Karena inilah fondasi otomasi: task yang bisa dijadwalkan dengan cron, dipicu dari pipeline CI/CD, atau dijalankan manual oleh operator — dengan output yang bisa dibaca mesin (exit code) dan manusia (progress bar).
Buat command pertama:
php bin/console make:command app:import-postsHasilnya adalah class yang mewarisi Command dengan attribute #[AsCommand]:
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:import-posts',
description: 'Mengimpor artikel dari file JSON.',
)]
class ImportPostsCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
// logika import...
$output->writeln('Import selesai.');
return Command::SUCCESS;
}
}Aturan emas: execute() harus mengembalikan exit code — Command::SUCCESS (0), Command::FAILURE (1), atau Command::INVALID (2). Inilah yang membuat command bisa dipakai di script dan CI dengan andal.
Bedakan keduanya:
app:import-posts file.json.app:import-posts --dry-run.use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
protected function configure(): void
{
$this
->addArgument('file', InputArgument::REQUIRED, 'Path file JSON')
->addArgument('limit', InputArgument::OPTIONAL, 'Batasi jumlah post', 100)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Tampilkan tanpa menulis');
}Membacanya di execute():
$file = $input->getArgument('file');
$limit = (int) $input->getArgument('limit');
$isDryRun = $input->getOption('dry-run');
if ($isDryRun) {
$output->writeln("DRY-RUN: {$file} (maks {$limit} post)");
return Command::SUCCESS;
}VALUE_NONE berarti option bertipe boolean (--dry-run ada = true). Untuk nilai yang butuh parameter, pakai VALUE_REQUIRED.
Output polos cukup, tapi untuk UX CLI yang profesional gunakan SymfonyStyle:
use Symfony\Component\Console\Style\SymfonyStyle;
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$rows = [
['123', 'Artikel demo', 'published'],
['124', 'Artikel draft', 'draft'],
];
$io->table(['ID', 'Judul', 'Status'], $rows);
$io->section('Memproses...');
$io->progressStart(count($rows));
foreach ($rows as $row) {
// kerja per baris...
usleep(100000);
$io->progressAdvance();
}
$io->progressFinish();
$io->success('Selesai.');
$io->warning('Beberapa post dilewati.');
return Command::SUCCESS;
}| Helper | Fungsi |
|---|---|
$io->table() | Render tabel dengan header |
$io->progressStart/Advance/Finish | Progress bar |
$io->success/warning/error | Pesan berwarna berikon |
$io->ask()/confirm()/choice() | Input interaktif |
Output di bawah akan tampak seperti ini di terminal:
---------- ------------- --------
ID Judul Status
---------- ------------- --------
123 Artikel demo published
124 Artikel draft draft
---------- ------------- --------
Memproses...
8/8 [============================] 100%
// Success: Selesai.Command adalah service — jadi kalian bisa menyuntikkan service apa pun via constructor:
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}Inilah alasan command adalah "aplikasi tanpa HTTP": logika bisnis yang sama dipakai dari web (controller) dan CLI (command) dengan cara yang konsisten — misalnya app:import-posts memakai EntityManagerInterface untuk menulis ke database.
Command berpasangan sempurna dengan cron:
0 2 * * * cd /srv/myapp && php bin/console app:generate-reports
30 3 * * * cd /srv/myapp && php bin/console app:cleanup --days=14Pola umum tooling:
| Command | Kasus nyata |
|---|---|
app:import-posts file.json | Import data dari export sistem lain |
app:send-reminders | Kirim email reminder harian |
app:generate-reports | Laporan batch untuk stakeholder |
app:cleanup --days=N | Bersihkan file/log tua |
Tip
Untuk pekerjaan berat, jangan paksa command berjalan sinkron di cron — arahkan ke Messenger (episode 10) agar tugas masuk antrian dan diproses worker. Cron cukup jadi trigger, bukan eksekutor beban berat.
Command::SUCCESS/FAILURE.getArgument() tidak tergantung definisi; pastikan order saat memanggil.__construct — jika menambah dependency, jangan lupa memanggil parent::__construct() atau command error.Pada episode 9 ini, kalian telah membangun tooling CLI di atas Console.
Inti yang harus dibawa pulang:
#[AsCommand] dan method execute().SUCCESS, FAILURE, atau INVALID.--dry-run untuk simulasi.SymfonyStyle menghadirkan table, progress bar, dan feedback yang manusiawi.Di episode 10 selanjutnya kita melempar pekerjaan berat ke latar belakang: Messenger: Queue & Async Processing — message bus, handler, transport async (Doctrine/Redis/AMQP), retry & failure handling, dan praktik async notification dengan worker. Sampai jumpa di episode 10!