Skip to content

Last updated:

How-To: Add an Artisan Command

Use this recipe for any command that performs a batch operation, audits data, or wraps an existing service for ops/cron use. Reference: ScanPatientDuplicates and MarkReservationNoShows.

1. Generate the command class

bash
php artisan make:command DoSomething

This creates app/Console/Commands/DoSomething.php. Commands are auto-registered — no Kernel::$commands entry needed in Laravel 12.

2. Keep the command thin — delegate to a service

The command should only handle:

  • Parsing arguments/options
  • Calling one service method
  • Formatting output

The actual logic belongs in app/Services/. This keeps it testable without spinning up the console runner.

php
namespace App\Console\Commands;

use App\Services\PatientDuplicateScanner;
use Illuminate\Console\Command;

class ScanPatientDuplicates extends Command
{
    protected $signature = 'patient-duplicates:scan
        {--commit : Write new pending duplicate rows (default is dry-run)}';

    protected $description = 'Scan all patients for strict name-based duplicate pairs.';

    public function handle(PatientDuplicateScanner $scanner): int
    {
        $commit = (bool) $this->option('commit');

        $result = $scanner->scan($commit);

        $this->table(
            ['Metric', 'Value'],
            [
                ['Mode', $result->committed ? 'COMMITTED' : 'DRY RUN'],
                ['Patients scanned', number_format($result->patientsScanned)],
                ['New pairs', number_format($result->newPairsTotal())],
                ['Skipped (already recorded)', number_format($result->skippedExisting)],
            ],
        );

        return self::SUCCESS;
    }
}

3. Naming conventions

ConventionExample
Group commands by domain prefixpatient-duplicates:scan, queue:reservations:mark-no-shows
Use : to namespace, - inside segmentsapp:prune-activity-logs
cpr: prefix is reserved for ops/backup commandscpr:backup:database
Return self::SUCCESS / self::FAILURENever just return; — exit codes drive cron alerts

4. Default to dry-run for any destructive command

Commands that write data should default to read-only and require an explicit --commit (or --force) flag. This is the convention across patient-duplicates:scan and legacy:set-billing-doctor.

php
protected $signature = 'my-command {--commit}';

public function handle(): int
{
    if (! $this->option('commit')) {
        $this->warn('Dry run — pass --commit to apply changes.');
    }
    // ...
}

5. Output: prefer tables and labelled output

  • $this->table(headers, rows) for summary metrics
  • $this->info() for success messages
  • $this->warn() for dry-run / safe states
  • $this->error() for failures (also use return self::FAILURE)

Avoid raw echo — these helpers respect -q (quiet) and -v (verbose).

6. Test the command

Two layers — service first, command second:

php
// tests/Unit/Console/ScanPatientDuplicatesTest.php
it('prints DRY RUN by default and does not write rows', function () {
    Patient::factory()->createMany([
        ['first_name' => 'John', 'middle_name' => null, 'last_name' => 'Doe'],
        ['first_name' => 'John', 'middle_name' => null, 'last_name' => 'Doe'],
    ]);

    $this->artisan('patient-duplicates:scan')
        ->expectsOutputToContain('DRY RUN')
        ->assertSuccessful();

    expect(PatientDuplicate::count())->toBe(0);
});

it('--commit writes pending rows', function () {
    // ...
    $this->artisan('patient-duplicates:scan --commit')->assertSuccessful();
    expect(PatientDuplicate::count())->toBeGreaterThan(0);
});

Checklist

  • [ ] Service does the work; command does the I/O
  • [ ] Signature uses domain:verb namespacing
  • [ ] Destructive commands default to dry-run + --commit
  • [ ] Returns self::SUCCESS or self::FAILURE explicitly
  • [ ] Output via $this->info/warn/error/table (never echo)
  • [ ] Test asserts both dry-run and commit behaviour
  • [ ] If meant to run on cron, scheduled in routes/console.php

CPR - Clinical Patient Records