Building Custom Artisan Commands in Laravel
Prerequisites
- Laravel 10+
- A working understanding of the Laravel service container
- At least one mass of tangled manual deployment steps you’re ashamed of
- A mug of something warm (non-negotiable)
- The quiet confidence of someone who has never accidentally run
migrate:freshin production
What We’re Building
Custom Artisan commands. The kind that automate the boring stuff, generate reports, and generally make you look like you’ve got your life together. We’ll cover interactive prompts, progress bars, and scheduled execution so your app can do things at 3am while you’re asleep. Which, lets be honest, is the whole point of being a developer.
The Approach
- Create a basic command
- Add arguments and options
- Build interactive prompts
- Add progress feedback
- Schedule for automation
Nothing radical. Just solid, practical stuff that will save you hours of manual faffing about.
Step 1: Generate the Command
Laravel gives us a generator for this, because of course it does.
php artisan make:command GenerateMonthlyReport
That gives us the basic structure to work with:
// app/Console/Commands/GenerateMonthlyReport.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class GenerateMonthlyReport extends Command
{
protected $signature = 'report:monthly';
protected $description = 'Generate the monthly sales report';
public function handle(): int
{
$this->info('Generating monthly report...');
// Your logic here
$this->info('Report generated successfully!');
return Command::SUCCESS;
}
}
The $signature property is how you’ll call this from the terminal. Keep it short, keep it namespaced, keep it memorable. You’ll be typing it more than you think.
php artisan report:monthly
Step 2: Arguments and Options
A command that does exactly one thing with zero configuration isnt much use in the real world. Let’s make it flexible.
protected $signature = 'report:generate
{type : The type of report (sales, users, revenue)}
{--month= : The month to generate for (default: current)}
{--format=pdf : Output format (pdf, csv, json)}
{--email : Email the report to administrators}';
public function handle(): int
{
$type = $this->argument('type');
$month = $this->option('month') ?? now()->format('Y-m');
$format = $this->option('format');
$shouldEmail = $this->option('email');
$this->info("Generating {$type} report for {$month} in {$format} format");
if ($shouldEmail) {
$this->info('Will email to administrators');
}
return Command::SUCCESS;
}
Notice the difference between arguments (required, positional) and options (prefixed with --, optional). The = after an option name means it takes a value. Without it, its a boolean flag.
php artisan report:generate sales --month=2025-01 --format=csv --email

Step 3: Interactive Prompts
Sometimes you want to have a conversation with your user rather than making them memorise a wall of flags. Laravel has you covered.
public function handle(): int
{
$type = $this->choice(
'What type of report?',
['sales', 'users', 'revenue'],
0
);
$month = $this->ask('Which month?', now()->format('Y-m'));
$format = $this->choice('Output format?', ['pdf', 'csv', 'json'], 'pdf');
if ($this->confirm('Email the report?', false)) {
$recipients = $this->ask('Email addresses (comma-separated)');
}
$password = $this->secret('Enter encryption password');
return Command::SUCCESS;
}
The secret() method hides input, which is exactly what you want for passwords and exactly what you dont want when you’re trying to debug why the password isn’t working. Fun times.
Step 4: Progress Bars
Nobody wants to stare at a blinking cursor wondering if the command is actually doing anything or just having an existential crisis.
public function handle(): int
{
$users = User::all();
$this->info('Processing users...');
$bar = $this->output->createProgressBar(count($users));
$bar->start();
foreach ($users as $user) {
$this->processUser($user);
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info('Complete!');
return Command::SUCCESS;
}
If you want something cleaner, there’s a shorthand that does the same job with less ceremony:
$this->withProgressBar($users, function ($user) {
$this->processUser($user);
});
Step 5: Output Formatting
Laravel gives you several output styles so you can colour-code your terminal output like a proper professional.
public function handle(): int
{
$this->line('Normal text');
$this->info('Info message');
$this->warn('Warning message');
$this->error('Error message');
$this->comment('Comment');
$this->newLine(2);
$this->table(
['ID', 'Name', 'Email'],
User::all(['id', 'name', 'email'])->toArray()
);
return Command::SUCCESS;
}
That table() method is genuinely brilliant for quick debugging. Need to eyeball some data without firing up a database client? Sorted.
Step 6: Calling Other Commands
Commands can call other commands. This is either incredibly powerful or a recipe for chaos, depending on how much coffee you’ve had.
public function handle(): int
{
$this->info('Running database refresh...');
$this->call('migrate:fresh');
$this->info('Seeding...');
$this->call('db:seed', ['--class' => 'ProductionSeeder']);
if ($this->option('with-cache')) {
$this->callSilently('cache:clear');
}
return Command::SUCCESS;
}
callSilently() is your friend when you want to run a sub-command without polluting the output. Nobody needs to see cache clearing output. It happened. Move on.
Step 7: Long-Running Commands
Some commands take a while. Processing thousands of records, crunching numbers, contemplating the void. Chunking is essential here so you don’t run out of memory and crash the whole thing.
use Illuminate\Support\Facades\DB;
public function handle(): int
{
$this->info('Processing large dataset...');
DB::table('orders')
->orderBy('id')
->chunk(1000, function ($orders) {
foreach ($orders as $order) {
$this->processOrder($order);
}
$this->output->write('.');
});
$this->newLine();
return Command::SUCCESS;
}
For particularly memory-hungry operations, throw in some manual garbage collection:
User::chunk(500, function ($users) {
foreach ($users as $user) {
$this->process($user);
}
gc_collect_cycles();
});
Calling gc_collect_cycles() manually feels a bit desperate, but sometimes you’ve got to do what you’ve got to do.

Step 8: Scheduling
This is where it all comes together. You’ve built your beautiful command, now let it run itself.
// app/Console/Kernel.php (Laravel 10)
// or routes/console.php (Laravel 11)
use Illuminate\Support\Facades\Schedule;
Schedule::command('report:monthly')
->monthlyOn(1, '08:00')
->emailOutputTo('[email protected]')
->onSuccess(function () {
Notification::route('slack', config('services.slack.webhook'))
->notify(new ReportGenerated());
})
->onFailure(function () {
// Alert on failure
});
You can also set maintenance windows so your heavy jobs run at sensible hours:
Schedule::command('data:cleanup')
->daily()
->between('02:00', '04:00')
->withoutOverlapping();
withoutOverlapping() prevents multiple instances of the same command from running simultaneously. Because if your cleanup job takes longer than expected and a second one kicks off, you’re going to have a very interesting morning.
Step 9: Testing Commands
You test your commands. You do test your commands, right?

// tests/Feature/Commands/GenerateReportTest.php
use Illuminate\Support\Facades\Storage;
public function test_generates_monthly_report(): void
{
Storage::fake('reports');
$this->artisan('report:monthly', ['--month' => '2025-01'])
->expectsOutput('Generating monthly report...')
->expectsOutput('Report generated successfully!')
->assertSuccessful();
Storage::disk('reports')->assertExists('2025-01-report.pdf');
}
public function test_prompts_for_confirmation(): void
{
$this->artisan('data:cleanup')
->expectsConfirmation('Are you sure you want to delete old data?', 'yes')
->assertSuccessful();
}
Laravel’s test assertions for Artisan commands are genuinely excellent. You can assert on output, simulate user input for prompts, and check exit codes. No excuses.
What I’d Do Differently
Add --dry-run options to destructive commands from the very start. The number of times I’ve wanted to see what would happen without actually doing it… you’d think I’d have learned by now. Future me always ends up grateful when past me remembered to add one.

Artisan commands are criminally underrated. If you catch yourself running the same series of manual steps more than twice, that’s a command waiting to be written. Your future self will thank you.