Artisan Commands — Scheduled Work That Observes Itself

Artisan Commands — Scheduled Work That Observes Itself

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 16 of 35 Level: Intermediate Prerequisites: Jobs and QueuesModel Traits


What You’ll Learn

  • When to write a custom Artisan command vs. a Job
  • The LogsCommandMessages trait 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 CommandJob
Triggered byphp artisan or the schedulerdispatch() in code or the queue
Has console outputYes — $this->info()$this->table(), etc.No
Idempotency requiredLess critical (usually manual or scheduled)Critical (may retry)
Best forScheduled maintenance, imports, data fixes, reportsAsync 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 running
  • runInBackground() — runs the command in a subprocess so the scheduler doesn’t block
  • onOneServer() — 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 LogsCommandMessages trait writes to both terminal and log — all commands should use it.
  • Use withoutOverlapping() on any command that shouldn’t run concurrently. Use runInBackground() so the scheduler doesn’t block.
  • Return self::SUCCESS or self::FAILURE from handle() — 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 LogsCommandMessages trait’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.

Further Reading


← Jobs and Queues | Next: Caching Strategy →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

2 Comments