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
php artisan make:command DoSomethingThis 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.
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
| Convention | Example |
|---|---|
| Group commands by domain prefix | patient-duplicates:scan, queue:reservations:mark-no-shows |
Use : to namespace, - inside segments | app:prune-activity-logs |
cpr: prefix is reserved for ops/backup commands | cpr:backup:database |
Return self::SUCCESS / self::FAILURE | Never 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.
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 usereturn self::FAILURE)
Avoid raw echo — these helpers respect -q (quiet) and -v (verbose).
6. Test the command
Two layers — service first, command second:
// 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:verbnamespacing - [ ] Destructive commands default to dry-run +
--commit - [ ] Returns
self::SUCCESSorself::FAILUREexplicitly - [ ] Output via
$this->info/warn/error/table(neverecho) - [ ] Test asserts both dry-run and commit behaviour
- [ ] If meant to run on cron, scheduled in
routes/console.php
