Laravel Artisan Commands: Stop Doing Repetitive Tasks Like a Robot 🤖
Laravel Artisan Commands: Stop Doing Repetitive Tasks Like a Robot 🤖
Here's a confession: I spent the first six months of my Laravel career manually running database operations, copy-pasting scripts into tinker, and writing the same setup steps in a Notion doc that nobody ever read.
Then someone on my team said, "Why don't you just write an Artisan command?"
Reader, it changed my life.
What Even Is Artisan? 🎨
php artisan is Laravel's built-in CLI toolkit. You've used it. php artisan migrate, php artisan make:model — that's Artisan doing the heavy lifting.
But here's the thing most devs miss: you can make your own commands. And once you do, you'll wonder how you ever survived without them.
In production systems I've built, we have custom Artisan commands for:
- Syncing product catalogs from third-party suppliers
- Cleaning up orphaned media files
- Generating daily reports and shoving them into S3
- Seeding environment-specific demo data without nuking prod
All invoked with a single terminal command. Chef's kiss.
Your First Custom Command ⚡
Generating the boilerplate is, of course, one command:
php artisan make:command SyncProductCatalog
That drops a new file in app/Console/Commands/SyncProductCatalog.php. Open it and you'll see:
class SyncProductCatalog extends Command
{
protected $signature = 'catalog:sync';
protected $description = 'Sync products from supplier API';
public function handle(): int
{
// Your logic goes here
return Command::SUCCESS;
}
}
The $signature is what you type in the terminal. The handle() method is where the magic happens. That's the whole skeleton. No ceremony, no boilerplate soup.
Arguments and Options: Making Commands Flexible 🎯
Hard-coded commands are like hard-coded credentials — technically work, definitely shouldn't exist.
protected $signature = 'catalog:sync
{supplier : The supplier ID to sync}
{--dry-run : Preview changes without saving}
{--limit=100 : Max products to process}';
Now you can call it like:
php artisan catalog:sync acme --dry-run --limit=50
And in handle():
public function handle(): int
{
$supplier = $this->argument('supplier');
$isDryRun = $this->option('dry-run');
$limit = $this->option('limit');
$this->info("Syncing {$limit} products from {$supplier}...");
if ($isDryRun) {
$this->warn('DRY RUN MODE — no changes will be saved');
}
// actual sync logic
return Command::SUCCESS;
}
Real Talk: The --dry-run pattern saved us from a supplier sync that would've wiped 4,000 product records in production. Always build dry-run into any command that writes data. You'll thank yourself at 2am.
Output That Doesn't Look Like 1995 🖥️
Artisan gives you beautiful output helpers that your future self will appreciate when tailing logs at midnight:
$this->info('All good here ✓'); // Green
$this->warn('Something is sus...'); // Yellow
$this->error('It broke. It really broke.'); // Red
$this->line('Just a regular line'); // Plain
For longer operations, the progress bar is your best friend:
$products = Product::cursor(); // cursor() for memory efficiency!
$bar = $this->output->createProgressBar($products->count());
$bar->start();
foreach ($products as $product) {
$this->syncProduct($product);
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info('Sync complete!');
As a Technical Lead, I've learned that a command with no output is a command nobody trusts. Show your work.
Scheduling Commands: Goodbye Cron Hell 📅
Before Laravel's scheduler, every new recurring job meant a scary trip to crontab -e and three StackOverflow tabs explaining why * * * * * means "every minute" and not "every moment I feel like it."
Now you just add this to app/Console/Kernel.php:
protected function schedule(Schedule $schedule): void
{
$schedule->command('catalog:sync acme')
->dailyAt('02:00')
->onOneServer() // prevents duplicate runs in multi-server setup
->withoutOverlapping() // won't start a new run if previous is still going
->emailOutputOnFailure('[email protected]');
}
And your server's crontab only ever needs this one line:
* * * * * php /path/to/artisan schedule:run >> /dev/null 2>&1
One cron entry to rule them all. Tolkien would've approved.
A pattern that saved us in a real project: ->onOneServer() is critical when you're running multiple EC2 instances behind a load balancer. Without it, all three servers will happily try to sync the catalog simultaneously. The resulting race conditions are not fun to debug at scale.
Interactive Commands for Onboarding 🤝
Custom commands aren't just for automation — they're great for guided setup workflows too. New developer joins the team? One command to set them up:
public function handle(): int
{
$env = $this->choice('Which environment?', ['local', 'staging'], 0);
if ($this->confirm("Seed demo data for {$env}?", true)) {
$this->call('db:seed', ['--class' => 'DemoSeeder']);
$this->info('Demo data seeded!');
}
$this->table(
['Setting', 'Value'],
[
['Environment', $env],
['Cache Driver', config('cache.default')],
['Queue Driver', config('queue.default')],
]
);
return Command::SUCCESS;
}
Run php artisan env:setup and walk a junior through it. Beats a README that nobody reads and goes stale by Tuesday.
Pro Tips From the Trenches 🔥
1. Return proper exit codes
// Always return these, not random integers
return Command::SUCCESS; // 0
return Command::FAILURE; // 1
CI/CD pipelines, shell scripts, and monitoring tools all watch exit codes. If you return null (or nothing), you'll confuse every automation tool that calls your command.
2. Use $this->call() to compose commands
public function handle(): int
{
$this->call('migrate');
$this->call('cache:clear');
$this->call('config:cache');
$this->info('Deploy steps complete!');
return Command::SUCCESS;
}
Commands calling commands. It's turtles all the way down, and it's glorious.
3. Inject dependencies properly
Artisan commands are resolved from the service container, which means constructor injection just works:
public function __construct(
private readonly ProductSyncService $syncService,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
No app()->make() hacks. Clean, testable, proper.
4. Test your commands
Yes, you can (and should) test Artisan commands:
$this->artisan('catalog:sync acme --dry-run')
->expectsOutput('DRY RUN MODE — no changes will be saved')
->assertExitCode(0);
One of those things that feels unnecessary until your command silently breaks in prod and you wish you'd written the test.
Real Talk: When to Build a Command vs. When Not To 💬
Build a command when:
- You're running the same terminal steps more than twice
- Another developer needs to run this process
- You want it on a schedule
- It's a maintenance task (cleanup, sync, report)
Don't build a command when:
- It should be a queue job (user-triggered, needs retry logic)
- It should be an API endpoint (other services need to trigger it)
- It's a one-time migration (just put it in a migration file)
The line blurs sometimes, and that's okay. As a Technical Lead, I've seen commands that should've been jobs, jobs that should've been commands, and migration files that contained entire features. Ship the pragmatic solution, document the tradeoff, refactor when it matters.
The Bottom Line 🚀
Custom Artisan commands are one of those Laravel features that feel small until you're four years into a project and your entire operations playbook is a collection of php artisan invocations.
Start simple: find one thing you do manually every week. Write a command for it. Schedule it if it makes sense. Add good output. Test it.
Your future self — the one who just woke up to an alert and needs to fix something fast — will thank you for having a clean, reliable command instead of a half-remembered bash incantation from 2023.
Bonus tip: Document your custom commands with good $description strings. php artisan list becomes your team's real README.
Built something cool with Artisan? Hit me up on LinkedIn — I'd love to see what you've automated.
Want more Laravel deep dives? Star the blog on GitHub and keep building!
Now go automate something. You've earned the coffee break. ☕