Skip to content
Package Toolkit
GitHub repository

Commands

Register your package's Artisan commands — discovered automatically, listed explicitly, or constructed by hand.

On this page 10
public function hasCommands(string|array|null $commandsClass = null, string $directory = 'Commands'): static
public function hasCommand(string $commandClass): static

Commands are registered only when the application is running in the console, so a web request never constructs one.

Automatic discovery

src/BlogServiceProvider.phpphp
$packager
->name('Blog')
->hasCommands();
acme/blog/src/
├── BlogServiceProvider.php
└── Commands/
├── PruneCommand.php
├── ImportCommand.php
└── ReindexCommand.php

All three are registered. Each file is resolved to a fully qualified class name by matching its path against Composer's PSR-4 prefixes — the same map the autoloader uses — so the namespace does not have to be guessed from a convention.

The default directory has no ../

hasCommands() defaults to 'Commands', resolved from your provider's directory — normally src/Commands. Every other builder defaults to '../something', because every other resource lives outside src/. Commands are PHP classes, so they do not.

Discovery is not recursive. A src/Commands/Maintenance/ subdirectory needs its own call:

$packager
->hasCommands()
->hasCommands(directory: 'Commands/Maintenance');

That works because hasCommands() appends rather than replaces.

Listing commands explicitly

use Acme\Blog\Commands\{ImportCommand, PruneCommand};
 
$packager->hasCommands([
PruneCommand::class,
ImportCommand::class,
]);

Or one at a time:

$packager
->hasCommand(PruneCommand::class)
->hasCommand(ImportCommand::class);

hasCommand() is just hasCommands() with a single value. Both append, so mixing discovery with an explicit addition is fine:

$packager
->hasCommands() // everything in src/Commands
->hasCommand(\Acme\Blog\Support\LegacyImport::class); // plus one from elsewhere

A custom directory

$packager->hasCommands(directory: 'Console/Commands');
$packager->hasCommands(directory: 'Artisan');

The directory is still resolved relative to your provider's directory, and must exist — a missing one throws DirectoryNotFoundException.

Commands that need constructor arguments

hasCommands() deals in class strings, which Laravel resolves through the container. That covers constructor injection of anything the container can build:

src/Commands/ImportCommand.phpphp
namespace Acme\Blog\Commands;
 
use Acme\Blog\Import\Importer;
use Illuminate\Console\Command;
 
class ImportCommand extends Command
{
protected $signature = 'blog:import {file : Path to the export file} {--dry-run}';
 
protected $description = 'Import posts from an export file';
 
public function __construct(private Importer $importer)
{
parent::__construct();
}
 
public function handle(): int
{
$count = $this->importer->import(
$this->argument('file'),
dryRun: $this->option('dry-run'),
);
 
$this->info(trans('blog::messages.imported', ['count' => $count]));
 
return self::SUCCESS;
}
}

For something the container cannot resolve on its own, override packageCommands() on the provider and return an instance:

public function packageCommands(): array
{
return [
new ImportCommand(new Importer(config('blog.import.batch_size'))),
];
}

Those are merged with whatever hasCommands() found.

Writing a command

src/Commands/PruneCommand.phpphp
namespace Acme\Blog\Commands;
 
use Acme\Blog\Models\Post;
use Illuminate\Console\Command;
 
class PruneCommand extends Command
{
protected $signature = 'blog:prune
{--days=30 : Delete posts trashed more than this many days ago}
{--dry-run : Report what would be deleted without deleting it}';
 
protected $description = 'Permanently delete trashed blog posts';
 
public function handle(): int
{
$cutoff = now()->subDays((int) $this->option('days'));
 
$query = Post::onlyTrashed()->where('deleted_at', '<', $cutoff);
$count = $query->count();
 
if ($count === 0) {
$this->components->info('Nothing to prune.');
 
return self::SUCCESS;
}
 
if ($this->option('dry-run')) {
$this->components->warn("Would delete {$count} posts.");
 
return self::SUCCESS;
}
 
if (! $this->confirm("Permanently delete {$count} posts?", false)) {
return self::FAILURE;
}
 
$query->forceDelete();
$this->components->info("Deleted {$count} posts.");
 
return self::SUCCESS;
}
}

Prefix every command name with your short name. blog:prune, not prune. Command names are a flat global namespace, and a collision means one of the two commands simply never runs.

Scheduling

There is no scheduling builder. Register from the booted lifecycle hook, wrapped in app()->booted() because the scheduler is resolved after every provider has booted:

use Illuminate\Console\Scheduling\Schedule;
 
$packager->bootedPackage(function () {
app()->booted(function () {
app(Schedule::class)
->command('blog:prune --days=90')
->daily()
->onOneServer();
});
});

Generator commands

A command that scaffolds files should publish its stubs so consumers can change what it generates — see Stubs:

$packager
->name('Blog')
->hasCommands()
->hasStubs();
src/Commands/MakePostTypeCommand.phpphp
protected function getStub(): string
{
$published = base_path('stubs/blog/post-type.stub');
 
return file_exists($published)
? $published
: __DIR__.'/../../stubs/post-type.stub';
}

The install command is separate

php artisan blog:install is not registered through hasCommands() — it has its own builder. See The install command.

Introspection

$packager->isCommandable(); // bool
$packager->commands; // string[] — public property

Testing

test('the command is registered', function () {
expect(array_keys(app(\Illuminate\Contracts\Console\Kernel::class)->all()))
->toContain('blog:prune');
});
 
test('prune deletes trashed posts older than the cutoff', function () {
$old = Post::factory()->trashed(at: now()->subDays(60))->create();
$recent = Post::factory()->trashed(at: now()->subDay())->create();
 
$this->artisan('blog:prune', ['--days' => 30])
->expectsConfirmation('Permanently delete 1 posts?', 'yes')
->assertExitCode(0);
 
expect(Post::withTrashed()->find($old->id))->toBeNull()
->and(Post::withTrashed()->find($recent->id))->not->toBeNull();
});