An API has a side its consumers never see. Somebody issues a token from a terminal. Something prunes old rows every night. In the service that provisions sites, a sweep looks for work that got stuck. None of it arrives as an HTTP request, and all of it is part of the system you run.
Laravel gives that side two things: Artisan commands, for work a person starts, and the scheduler, for work the clock starts. This chapter treats both the way the rest of the book treated endpoints: thin, tested, and unable to fail unnoticed. That last part takes work, because a scheduled task that stops running throws no exception.
A Command Is a Controller for the Terminal
Chapter 5 gave operators an endpoint for issuing tokens. They need the same thing at a terminal, for the first operator token if for nothing else, and it should be a command anyone can repeat, not something typed into a console from memory.
php artisan make:command IssueConsumerToken
// app/Console/Commands/IssueConsumerToken.php
#[Signature('consumers:token
{consumer : The consumer ID}
{--ability=* : Abilities to grant}
{--days=365 : Days until the token expires}')]
#[Description('Issue an API token for a consumer')]
class IssueConsumerToken extends Command
{
// ...
}
In Laravel 13 the signature and the description are attributes on the class. The signature is the command’s contract, the way a FormRequest is an endpoint’s: it names every argument and option, describes each, and Laravel builds --help from it. (Laravel documentation: Artisan Console.)
public function handle(): int
{
$consumer = Consumer::find($this->argument('consumer'));
if ($consumer === null) {
$this->error('No such consumer.');
return self::FAILURE;
}
$token = $consumer->issueToken(
name: 'issued from the console',
abilities: $this->option('ability'),
days: (int) $this->option('days'),
);
$this->info('Shown once. Store it now:');
$this->line($token->plainTextToken);
return self::SUCCESS;
}
Like a controller method, it translates its input, hands the work to something else, and reports the result.
One Method, Two Doors
There are now two ways to issue a token: the endpoint from Chapter 5 and this command. If each contained the logic, they would drift. One would add a default expiry and the other wouldn’t.
So the logic lives in neither. Both call Consumer::issueToken(), the method Chapter 5 put on the model, and that method is where a rule belongs when both doors must obey it. The endpoint validated abilities in its FormRequest. The command has no FormRequest, so the check moves to where neither caller can skip it:
// app/Models/Consumer.php
public function issueToken(
string $name,
array $abilities,
int $days = 365,
): NewAccessToken {
if ($abilities === []) {
throw new InvalidArgumentException('No abilities.');
}
foreach ($abilities as $ability) {
Ability::from($ability); // throws on an unknown one
}
return $this->createToken(
name: $name,
abilities: $abilities,
expiresAt: now()->addDays($days),
);
}
An empty list is refused because Sanctum would read it as “no abilities,” and an operator who forgot the option would get a token that can do nothing, with no warning. An unknown ability is refused because a typo would do the same thing for one permission.
The FormRequest still validates. Its job is to give an HTTP caller a clear 422. The model’s check is the one that can’t be forgotten. When an operation has two ways in, the rule goes behind both.
Exit Codes Are the Command’s Status Codes
self::SUCCESS is zero. self::FAILURE is one. That return value is the only thing a deploy script, a scheduler, or a monitoring tool knows about what happened.
A command that prints “Something went wrong” in red and returns success has told every machine watching that all is well. Treat the exit code the way Chapter 2 treated the HTTP status: it is the part of the answer that software reads. The command above returns FAILURE for a consumer that doesn’t exist, and it does so with a message, not by letting an exception escape. The stuck-work sweep in Chapter 10 returns FAILURE when it refuses to act, and that non-zero exit is what lets the scheduler notice.