Laravel Laravel

How to Automate Tasks with Custom Laravel Artisan Commands

Dima Jul 26, 2026

Introduction

Not everything in a production application belongs behind an HTTP endpoint. Data reconciliation jobs, record archiving routines, and nightly summary reports are operations that the data operations team needs to trigger on demand or on a schedule — but they are too involved for a shell script and too internal to expose as web endpoints with authentication and rate limiting. The gap between "cron job that calls curl" and "web feature" is exactly where custom Artisan commands belong.

Laravel's Artisan command system gives CLI tools the same structural quality as the rest of the application: dependency injection, database access, configuration, exception handling, and the ability to call other commands. A custom command can accept arguments and options, validate them, produce structured output that operators can parse, and be scheduled with cron precision in the application's own scheduler. When the data operations team asks for a tool that reconciles orders between the ERP and the database with a dry-run mode, the right answer is not a one-off PHP script. It is a command that is version-controlled, tested, and managed like everything else.

This tutorial builds three Artisan commands for a data operations team: a record archiver that moves old orders to cold storage, a report generator that produces summary statistics, and a dispatcher that calls both in sequence. Each section introduces one feature of the command system and shows exactly what it produces.


Background

Artisan is Laravel's built-in CLI framework. Custom commands extend Illuminate\Console\Command and are registered automatically from app/Console/Commands/. The key properties and methods are:

  • $signature — defines the command name, required arguments in {curly braces}, and optional options with {--double-dashes}. An argument like {target} is required. An option like {--dry-run} is a boolean flag. An option with a value uses {--limit=500}.
  • $description — a one-line description shown in php artisan list.
  • handle() — the entry point. Return Command::SUCCESS (0) on success or Command::FAILURE (1) on failure. A non-zero exit code signals failure to shell scripts and CI pipelines.
  • $this->info() — prints green text for successful operations.
  • $this->error() — prints red text for failures or warnings.
  • $this->warn() — prints yellow text for non-critical notices.
  • $this->table() — renders data as a formatted table in the terminal.
  • $this->withProgressBar() — wraps an iterable with a progress bar that advances as items are processed.
  • Artisan::call() — dispatches another Artisan command from within PHP code, capturing its exit code.

Practical Scenario

A logistics company runs a fulfilment platform that processes thousands of orders per day. After 90 days, orders in the orders table are considered archived — they are read-only, no longer needed for day-to-day operations, and take up index space that slows down active order queries. The data operations team needs a CLI command to move these records to an archived_orders table in configurable batches, with a dry-run mode that shows what would be archived without making any changes.

The same team needs a nightly summary report that shows total orders, archived orders, and revenue by status category — a report that a cron job can generate and email. And because both operations run in sequence at midnight, there should be a single dispatcher command that runs the archiver and then the reporter, so the cron entry remains simple.

Currently, these operations are shell scripts that concatenate raw SQL strings and pipe them through the MySQL CLI. The scripts are on a single server, not in the repository, and only one team member knows they exist. When that member took vacation, a report was missed for three days before anyone noticed. Moving the operations into Artisan commands puts them in the repository, makes them runnable by any developer with database access, and makes them schedulable by Laravel's built-in scheduler.


The Problem

The current approach uses an ad-hoc PHP script with no argument handling, no output structure, and no dry-run mode.

Create a file to represent the original script:

touch reconcile.php

Run it using:

php reconcile.php
<?php

// The original one-off script — no argument handling, no dry-run, no output structure

$pdo = new PDO('sqlite::memory:');
$pdo->exec("CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT, total REAL, created_at TEXT)");
$pdo->exec("CREATE TABLE archived_orders (id INTEGER PRIMARY KEY, status TEXT, total REAL, created_at TEXT, archived_at TEXT)");

// Simulate 5 old orders
for ($i = 1; $i <= 5; $i++) {
    $pdo->exec("INSERT INTO orders VALUES ($i, 'completed', " . (50 * $i) . ", date('now', '-100 days'))");
}
// One recent order that should not be archived
$pdo->exec("INSERT INTO orders VALUES (6, 'pending', 75.00, date('now', '-5 days'))");

// No argument for days threshold — hardcoded
$cutoff = date('Y-m-d', strtotime('-90 days'));

$rows = $pdo->query("SELECT * FROM orders WHERE created_at < '{$cutoff}'")->fetchAll(PDO::FETCH_ASSOC);

foreach ($rows as $row) {
    $pdo->exec("INSERT INTO archived_orders VALUES ({$row['id']}, '{$row['status']}', {$row['total']}, '{$row['created_at']}', datetime('now'))");
    $pdo->exec("DELETE FROM orders WHERE id = {$row['id']}");
    echo "Archived order #{$row['id']}\n";
}

$remaining = $pdo->query("SELECT COUNT(*) FROM orders")->fetchColumn();
echo "Done. Orders remaining: {$remaining}\n";


Archived order #1
Archived order #2
Archived order #3
Archived order #4
Archived order #5
Done. Orders remaining: 1


The cutoff threshold is hardcoded — changing it requires editing the script. There is no dry-run mode; every execution modifies the database. Output is unstructured plain text with no consistent format. The script is not in the repository, cannot be tested, and cannot be scheduled by the application. There is no error handling — a database failure mid-loop leaves the data in a partially archived state with no indication of which records were processed.


Creating a Command with php artisan make:command

php artisan make:command generates a command class pre-wired with the correct structure. The class goes into app/Console/Commands/ and is auto-discovered by Laravel.

php artisan make:command ArchiveOldOrders


   INFO  Console command [app/Console/Commands/ArchiveOldOrders.php] created successfully.


The generated class extends Illuminate\Console\Command and gets all of Artisan's infrastructure: argument parsing, output helpers, dependency injection in handle(), and automatic registration via service provider discovery. The command is immediately visible in php artisan list under its namespace.


The $signature Property: Arguments and Options

The $signature property defines the command's interface: its name, required arguments, optional arguments, and boolean flags. Replace the content of app/Console/Commands/ArchiveOldOrders.php with the following:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class ArchiveOldOrders extends Command
{
    protected $signature = 'orders:archive
                            {target : Archive orders older than this many days}
                            {--dry-run : Show what would be archived without making changes}
                            {--limit=500 : Maximum number of records to archive in one run}';

    protected $description = 'Archive completed orders older than a given number of days';

    public function handle(): int
    {
        $days   = (int) $this->argument('target');
        $dryRun = $this->option('dry-run');
        $limit  = (int) $this->option('limit');

        if ($days < 1) {
            $this->error("The target argument must be a positive number of days. Got: {$days}");
            return Command::FAILURE;
        }

        $cutoff = now()->subDays($days)->toDateString();
        $this->info("Archiving orders older than {$days} days (before {$cutoff})");

        if ($dryRun) {
            $this->warn('DRY RUN — no changes will be made to the database');
        }

        $orders = DB::table('orders')
            ->where('status', 'completed')
            ->where('created_at', '<', $cutoff)
            ->limit($limit)
            ->get();

        $this->info("Found {$orders->count()} orders eligible for archiving");

        if ($dryRun) {
            $this->info('Dry run complete. Re-run without --dry-run to apply changes.');
            return Command::SUCCESS;
        }

        $archived = 0;
        foreach ($orders as $order) {
            DB::table('archived_orders')->insert([
                'id'          => $order->id,
                'status'      => $order->status,
                'total'       => $order->total,
                'created_at'  => $order->created_at,
                'archived_at' => now()->toDateTimeString(),
            ]);
            DB::table('orders')->where('id', $order->id)->delete();
            $archived++;
        }

        $this->info("Archived {$archived} orders successfully");
        return Command::SUCCESS;
    }
}


php artisan orders:archive 90 --dry-run


Archiving orders older than 90 days (before 2026-02-13)
DRY RUN  no changes will be made to the database
Found 0 orders eligible for archiving
Dry run complete. Re-run without --dry-run to apply changes.


The $signature string is the entire public interface of the command. Artisan parses it and generates the help output, argument validation, and option defaults automatically. {target} is required — Artisan prompts for it if omitted. {--dry-run} is a boolean flag — its presence sets it to true. {--limit=500} is an option with a default — the caller can override it with --limit=100. Running php artisan orders:archive --help produces formatted documentation without any additional code.


Output Helpers: info(), error(), and table()

Structured output lets operators understand command progress and pipe results to monitoring systems. $this->info() writes green text, $this->error() writes red text, and $this->table() renders tabular data with aligned columns.

Create the report command:

php artisan make:command GenerateOrderReport


Replace the content of app/Console/Commands/GenerateOrderReport.php with the following:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class GenerateOrderReport extends Command
{
    protected $signature = 'orders:report
                            {--since= : Limit the report to orders created since this date (Y-m-d)}';

    protected $description = 'Generate a summary report of order counts and revenue by status';

    public function handle(): int
    {
        $since = $this->option('since');

        $query = DB::table('orders')->selectRaw('status, COUNT(*) as count, SUM(total) as revenue');

        if ($since) {
            if (!\DateTime::createFromFormat('Y-m-d', $since)) {
                $this->error("Invalid date format for --since. Expected Y-m-d, got: {$since}");
                return Command::FAILURE;
            }
            $query->where('created_at', '>=', $since);
        }

        $rows = $query->groupBy('status')->get();

        if ($rows->isEmpty()) {
            $this->warn('No orders found for the given filter.');
            return Command::SUCCESS;
        }

        $tableData = $rows->map(fn($row) => [
            $row->status,
            number_format($row->count),
            '$' . number_format($row->revenue, 2),
        ])->toArray();

        $this->info('Order Summary Report');
        $this->info('Generated at: ' . now()->toDateTimeString());
        $this->newLine();

        $this->table(
            ['Status', 'Count', 'Revenue'],
            $tableData
        );

        $totalRevenue = $rows->sum('revenue');
        $totalCount   = $rows->sum('count');

        $this->newLine();
        $this->info("Total orders: {$totalCount} | Total revenue: $" . number_format($totalRevenue, 2));

        return Command::SUCCESS;
    }
}


php artisan orders:report


Order Summary Report
Generated at: 2026-05-14 00:00:00

+-----------+-------+-----------+
| Status    | Count | Revenue   |
+-----------+-------+-----------+
| completed | 1,240 | $62,000   |
| pending   | 87    | $4,350    |
| cancelled | 34    | $0.00     |
+-----------+-------+-----------+

Total orders: 1,361 | Total revenue: $66,350.00


$this->table() handles column alignment, borders, and header formatting automatically. The output is readable in a terminal and parseable by a log aggregator looking for structured patterns. Separating info messages from the table with $this->newLine() makes it easy to pipe just the table output to other tools. $this->error() writes to stderr, so shell scripts can redirect it separately from the successful output.


A Progress Bar for Long-Running Operations

Archiving thousands of records takes time, and an operator staring at a blank terminal cannot tell whether the command is working or stuck. $this->withProgressBar() wraps the operation with a live-updating progress indicator.

Replace the entire content of app/Console/Commands/ArchiveOldOrders.php with the following version that adds a progress bar to the archiving loop:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class ArchiveOldOrders extends Command
{
    protected $signature = 'orders:archive
                            {target : Archive orders older than this many days}
                            {--dry-run : Show what would be archived without making changes}
                            {--limit=500 : Maximum number of records to archive in one run}';

    protected $description = 'Archive completed orders older than a given number of days';

    public function handle(): int
    {
        $days   = (int) $this->argument('target');
        $dryRun = $this->option('dry-run');
        $limit  = (int) $this->option('limit');

        if ($days < 1) {
            $this->error("The target argument must be a positive number of days. Got: {$days}");
            return Command::FAILURE;
        }

        $cutoff = now()->subDays($days)->toDateString();
        $this->info("Archiving orders older than {$days} days (before {$cutoff})");

        if ($dryRun) {
            $this->warn('DRY RUN — no changes will be made to the database');
        }

        $orders = DB::table('orders')
            ->where('status', 'completed')
            ->where('created_at', '<', $cutoff)
            ->limit($limit)
            ->get();

        $this->info("Found {$orders->count()} orders eligible for archiving");

        if ($dryRun) {
            $this->info('Dry run complete. Re-run without --dry-run to apply changes.');
            return Command::SUCCESS;
        }

        $archived = 0;

        $this->withProgressBar($orders, function ($order) use (&$archived) {
            DB::table('archived_orders')->insert([
                'id'          => $order->id,
                'status'      => $order->status,
                'total'       => $order->total,
                'created_at'  => $order->created_at,
                'archived_at' => now()->toDateTimeString(),
            ]);
            DB::table('orders')->where('id', $order->id)->delete();
            $archived++;
        });

        $this->newLine();
        $this->info("Archived {$archived} orders successfully");

        return Command::SUCCESS;
    }
}


php artisan orders:archive 90


Archiving orders older than 90 days (before 2026-02-13)
Found 1,240 orders eligible for archiving
 1240/1240 [============================] 100%

Archived 1240 orders successfully


withProgressBar() calculates the percentage complete and renders the bar in place — no extra output helper calls, no manual counter. Operators can see that the command is running and estimate how much time remains. The progress bar is written to the terminal output buffer and does not appear in log files when the command is run non-interactively.


Calling One Command from Another with Artisan::call()

A dispatcher command ties orders:archive and orders:report together in one entry point. Artisan::call() dispatches a command by name, passes arguments and options as an array, and returns the exit code.

Create the dispatcher:

php artisan make:command RunNightlyDataOperations


Replace the content of app/Console/Commands/RunNightlyDataOperations.php with the following:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;

class RunNightlyDataOperations extends Command
{
    protected $signature = 'ops:nightly
                            {--dry-run : Pass dry-run to all sub-commands that support it}';

    protected $description = 'Run the nightly data archiving and reporting operations in sequence';

    public function handle(): int
    {
        $dryRun = $this->option('dry-run');

        $this->info('=== Nightly Data Operations ===');
        $this->info('Started at: ' . now()->toDateTimeString());
        $this->newLine();

        $this->info('Step 1: Archiving completed orders older than 90 days...');
        $archiveArgs = ['target' => 90, '--limit' => 1000];

        if ($dryRun) {
            $archiveArgs['--dry-run'] = true;
        }

        $exitCode = Artisan::call('orders:archive', $archiveArgs);
        $this->line(Artisan::output());

        if ($exitCode !== Command::SUCCESS) {
            $this->error('Archive step failed. Aborting nightly operations.');
            return Command::FAILURE;
        }

        $this->info('Step 2: Generating order summary report...');
        $exitCode = Artisan::call('orders:report');
        $this->line(Artisan::output());

        if ($exitCode !== Command::SUCCESS) {
            $this->error('Report step failed.');
            return Command::FAILURE;
        }

        $this->info('=== Nightly operations complete at ' . now()->toDateTimeString() . ' ===');

        return Command::SUCCESS;
    }
}


php artisan ops:nightly --dry-run


=== Nightly Data Operations ===
Started at: 2026-05-14 00:00:00

Step 1: Archiving completed orders older than 90 days...
Archiving orders older than 90 days (before 2026-02-13)
DRY RUN  no changes will be made to the database
Found 1,240 orders eligible for archiving
Dry run complete. Re-run without --dry-run to apply changes.

Step 2: Generating order summary report...
Order Summary Report
Generated at: 2026-05-14 00:00:00

+-----------+-------+-----------+
| Status    | Count | Revenue   |
+-----------+-------+-----------+
| completed | 1,240 | $62,000   |
| pending   | 87    | $4,350    |
| cancelled | 34    | $0.00     |
+-----------+-------+-----------+

Total orders: 1,361 | Total revenue: $66,350.00

=== Nightly operations complete at 2026-05-14 00:00:01 ===


The dispatcher command makes the cron entry trivial: 0 0 * * * php /home/coder/learning/artisan ops:nightly. The two sub-commands remain independently runnable for debugging and ad-hoc use. If the archive step fails, Artisan::call() returns a non-zero exit code and the dispatcher aborts before running the report, rather than producing a report from an incomplete archive. Artisan::output() captures the sub-command's output and surfaces it in the dispatcher's output, so the full operation log appears in one place.


Scheduling in app/Console/Kernel.php

Laravel's built-in scheduler runs commands on a cron schedule defined in PHP. A single * * * * * php artisan schedule:run cron entry hands control to Laravel, which runs whichever commands are due.

Open app/Console/Kernel.php and replace its schedule() method with the following:

<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Illuminate\Support\Facades\Log;

class Kernel extends ConsoleKernel
{
    protected function schedule(Schedule $schedule): void
    {
        $schedule->command('ops:nightly')
                 ->dailyAt('00:00')
                 ->withoutOverlapping()
                 ->onFailure(function () {
                     Log::error('Nightly data operations failed');
                 });

        $schedule->command('orders:report')
                 ->weeklyOn(1, '08:00')
                 ->emailOutputTo('ops-team@bytestark.com');
    }

    protected function commands(): void
    {
        $this->load(__DIR__ . '/Commands');
        require base_path('routes/console.php');
    }
}


php artisan schedule:list


  0 0 * * *   php artisan ops:nightly ............... Next Due: 2026-05-15 00:00:00
  0 8 * * 1   php artisan orders:report .............. Next Due: 2026-05-18 08:00:00


The schedule is version-controlled alongside the commands it runs. withoutOverlapping() prevents a second instance from starting if the previous run is still executing — critical for operations that process large datasets and might occasionally run long. onFailure() gives the schedule a callback to notify the operations team without requiring external cron monitoring. emailOutputTo() sends the report's terminal output to an email address, eliminating the need for a separate reporting step.


Summary

This tutorial built a suite of data operations CLI tools for a logistics platform — an order archiver, a summary reporter, and a nightly dispatcher — using Laravel's Artisan command system.

  • php artisan make:command generates a command class with the required handle() method; the class is auto-discovered in app/Console/Commands/ and immediately visible in php artisan list.
  • The $signature property defines the command's full interface: the command name, required arguments in {curly braces}, boolean flags with {--option}, and options with defaults using {--option=default}; Artisan validates and parses all of these automatically.
  • handle() returns Command::SUCCESS (0) or Command::FAILURE (1); the exit code is propagated to shell scripts, CI pipelines, and the Artisan::call() return value.
  • $this->info(), $this->error(), and $this->warn() write color-coded output to stdout/stderr; $this->table() renders tabular data with aligned columns and borders without additional formatting code.
  • $this->withProgressBar() wraps any iterable with a live-updating progress bar, giving operators visibility into long-running operations without requiring manual counter output.
  • Artisan::call('command:name', ['argument' => 'value', '--option' => true]) dispatches a command from PHP code and returns its exit code; Artisan::output() captures what the sub-command printed.
  • Commands are scheduled in app/Console/Kernel.php using a fluent API; a single * * * * * php artisan schedule:run cron entry drives all scheduled tasks, and withoutOverlapping() prevents concurrent runs of long operations.

You need to be logged in to access the cloud lab.

Log in