Artisan Commands — Scheduled Work That Observes Itself
Series: Every Laravel Project Should Have These Building Blocks
Part: 16 of 35 Level: Intermediate Prerequisites: Jobs and Queues, Model Traits
What You’ll Learn
- When to write a custom Artisan command vs. a Job
- The
LogsCommandMessagestrait for dual console+log output - Scheduling commands with appropriate guards
- Commands for data imports, cleanup, and maintenance
- How commands and jobs work together
Commands vs. Jobs
Both commands and jobs run code outside of an HTTP request. The difference is context:
| Artisan Command | Job | |
|---|---|---|
| Triggered by | php artisan or the scheduler | dispatch() in code or the queue |
| Has console output | Yes — $this->info(), $this->table(), etc. | No |
| Idempotency required | Less critical (usually manual or scheduled) | Critical (may retry) |
| Best for | Scheduled maintenance, imports, data fixes, reports | Async work triggered by user actions |
In practice: use a Command when something needs to run on a schedule or be triggered manually. Use a Job when something needs to run in the background without blocking a request.
Commands often dispatch Jobs:
class ProcessPendingOrdersCommand extends Command
{
protected $signature = 'orders:process-pending';
public function handle(): int
{
$orders = Order::pending()->get();
foreach ($orders as $order) {
ProcessSingleOrder::dispatch($order);
}
$this->logInfo("Dispatched {$orders->count()} orders to the queue.");
return self::SUCCESS;
}
}
The LogsCommandMessages Trait
Without this trait, output from scheduled commands disappears unless you redirect stdout to a log file, and manual runs show output but it’s not persisted.
The trait writes to both the console and the log simultaneously:
// app/Support/Traits/Console/LogsCommandMessages.php
trait LogsCommandMessages
{
public function logInfo(string $message, array $context = []): void
{
$this->info($message);
logger()->info($message, $context);
}
public function logWarning(string $message, array $context = []): void
{
$this->warn($message);
logger()->warning($message, $context);
}
public function logError(string $message, array $context = []): void
{
$this->error($message);
logger()->error($message, $context);
}
}
Every command in the projects uses this:
// app/Console/Commands/SyncProductsFromSupplierCommand.php
class SyncProductsFromSupplierCommand extends Command
{
use LogsCommandMessages;
protected $signature = 'products:sync-supplier {supplier_id? : Sync a specific supplier}';
protected $description = 'Sync products from supplier API';
public function handle(SupplierSyncService $sync): int
{
$this->logInfo('Starting supplier product sync...');
$suppliers = $this->argument('supplier_id')
? Supplier::where('id', $this->argument('supplier_id'))->get()
: Supplier::active()->get();
$synced = 0;
$failed = 0;
foreach ($suppliers as $supplier) {
try {
$count = $sync->syncProducts($supplier);
$this->logInfo("Synced {$count} products from {$supplier->name}");
$synced += $count;
} catch (Throwable $e) {
$this->logError("Failed to sync {$supplier->name}: {$e->getMessage()}", [
'supplier_id' => $supplier->id,
'exception' => $e::class,
]);
$failed++;
}
}
$this->logInfo("Sync complete. {$synced} products synced, {$failed} suppliers failed.");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
The key benefits:
- When run manually: you see output in the terminal
- When run on schedule: output goes to
storage/logs/laravel.log - The context array (
['supplier_id' => 7]) is logged but not printed — it’s for log analysis, not human reading
Scheduling Commands
In Laravel 11+, schedule commands in routes/console.php:
use Illuminate\Support\Facades\Schedule;
// Run every hour on production only
Schedule::command('products:sync-supplier')
->hourly()
->runInBackground()
->withoutOverlapping();
// Every night at 2am
Schedule::command('reports:generate-daily')
->dailyAt('02:00')
->runInBackground()
->emailOutputOnFailure(config('mail.to'));
// Every minute (queue health check)
Schedule::job(new HeartBeatJob)->everyMinute()->onQueue('default');
// Every weekday at 8am
Schedule::command('orders:send-morning-summary')
->weekdays()
->at('08:00');
Three scheduling guards you should know:
withoutOverlapping()— won’t start a new run if the previous one is still runningrunInBackground()— runs the command in a subprocess so the scheduler doesn’t blockonOneServer()— in multi-server deployments, only runs on one server at a time
Maintenance Commands
The most common pattern: a command that cleans up old records or logs:
// app/Console/Commands/PruneOldRequestLogsCommand.php
class PruneOldRequestLogsCommand extends Command
{
use LogsCommandMessages;
protected $signature = 'logs:prune-requests {--days=30 : Keep logs for this many days}';
protected $description = 'Delete request logs older than N days';
public function handle(): int
{
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$this->logInfo("Deleting request logs older than {$cutoff->toDateString()}...");
$deleted = RequestLog::where('created_at', '<', $cutoff)->delete();
$this->logInfo("Deleted {$deleted} request log records.", ['days' => $days]);
return self::SUCCESS;
}
}
Schedule it:
Schedule::command('logs:prune-requests --days=30')
->weekly()
->sundays()
->at('03:00');
Import Commands
For one-off or recurring data imports:
class ImportDealersFromCsvCommand extends Command
{
use LogsCommandMessages;
protected $signature = 'dealers:import {file : Path to the CSV file}';
protected $description = 'Import dealers from a CSV file';
public function handle(DealerImportService $import): int
{
$file = $this->argument('file');
if (! file_exists($file)) {
$this->logError("File not found: {$file}");
return self::FAILURE;
}
$this->logInfo("Starting dealer import from {$file}...");
$result = $import->fromCsv($file);
$this->logInfo("Import complete.", [
'imported' => $result->imported,
'skipped' => $result->skipped,
'failed' => $result->failed,
]);
if ($result->failed > 0) {
$this->logWarning("{$result->failed} rows failed. Check logs for details.");
}
return $result->failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
Interactive Commands with Prompts
For commands that need user confirmation in manual runs:
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\table;
class ResetUserPasswordCommand extends Command
{
protected $signature = 'users:reset-password {email}';
protected $description = 'Reset a user password and send email';
public function handle(): int
{
$user = User::where('email', $this->argument('email'))->firstOrFail();
table(['Field', 'Value'], [
['ID', $user->id],
['Name', $user->name],
['Email', $user->email],
]);
if (! confirm('Reset this user\'s password?')) {
$this->info('Aborted.');
return self::SUCCESS;
}
$newPassword = Str::password(16);
$user->update(['password' => bcrypt($newPassword)]);
Mail::to($user->email)->send(new PasswordResetMail($user, $newPassword));
$this->info("Password reset and email sent to {$user->email}.");
return self::SUCCESS;
}
}
Laravel Prompts (laravel/prompts) gives you interactive forms, selects, confirmation dialogs, and progress bars that degrade gracefully in non-interactive environments (CI, scheduled tasks).
Key Takeaways
- Commands for scheduled/manual operations. Jobs for async work triggered by user actions.
- The
LogsCommandMessagestrait writes to both terminal and log — all commands should use it. - Use
withoutOverlapping()on any command that shouldn’t run concurrently. UserunInBackground()so the scheduler doesn’t block. - Return
self::SUCCESSorself::FAILUREfromhandle()— these set the exit code and let the scheduler detect failures. - Maintenance commands (prune logs, archive records) should be scheduled and run on off-peak hours.
- Import commands should log counts (imported, skipped, failed) for every run.
Tips and Gotchas
⚠️ Warning: Scheduled commands run as the web user in production. If a command writes files, make sure the storage paths are writable by that user. Commands that fail silently (no exception, just a bad exit code) can be missed for weeks without proper logging.
💡 Tip: Use
$this->withoutOverlapping()in the scheduler for commands that might run longer than their schedule interval. Without it, a slow command accumulates parallel instances — each one starting before the last one finishes.
🔥 Expert Note: The
LogsCommandMessagestrait’s dual output (console + log channel) is essential for commands run on a schedule. In production there’s no console to watch, so the log channel is your only visibility. Commands that only use$this->info()are invisible in production.
2 Comments