Artisan commands are a useful part of the Laravel ecosystem, used for back-filling, easier scheduling of repetitive tasks, etc. As the project grows, the number of artisan commands is certain to increase with it. With a high number of commands, the risk of duplicated code or the same logic being implemented in several different ways increases. It is important to have rules and guidelines for writing commands (as is true for every part of the code), and it becomes especially important with a large number of commands, and even more so in large teams. However, enforcing those rules is not always straightforward. One way of handling this problem is by using attributes.
Attributes
Attributes were introduced in PHP v8.0. They are used to add metadata to the code and can be inspected using the Reflection API. I feel like they are somewhat neglected in the Laravel world, although there are some features where they can be used. For example, instead of registering a singleton class in AppServiceProvider like this:
// AppServiceProvider.php
public function register(): void
{
$this->app->singleton(PaymentService::class);
}
we can now do
use Illuminate\Contracts\Container\Attributes\Singleton;
#[Singleton]
class PaymentService
{
// ...
}
The elegance and simplicity of the second, more modern approach, should be appreciated more.
The example guidelines
This is a real-life example. I have worked on a project that had a multi-tenant architecture. Each tenant had many projects, and each project had many users. We had more than 20 artisan commands, and we had several strict rules for writing them. Some of them were:
getHelp()method must be implemented. This is to make sure that artisan commands are properly documented, with examples. The importance of implementing thegetHelp()method is immediately clear to everyone, but the problem is that developers sometimes simply forget to add it.- If it makes sense for the use case of the artisan command, the command should be able to be run on tenant, project, and user level. This is important for flexibility and having an easy way to define the scope of impact of the artisan command. The problem is that this means that each command will have logic for input validation, which can lead to duplicated code.
- Some commands should be run once and only once, and we must make sure to warn users about this. This applies to commands for back-filling data and similar use cases.
I will go over each of them and show how to handle them, but first we need a setup.
Setup
Laravel artisan commands are classes that extend Command class. Since we want to enhance them, we need a class that extends Command class. It should be abstract, since it is incomplete, and it will be extended by our enhanced commands.
abstract class BaseEnhancedCommand extends Command
Commands can have options, which can be optional, required, or flags. Creating three attributes called OptionalOption, RequiredOption, and FlagOption gives us the flexibility to add specific rules for their formatting, description, and similar properties. Here, I want all options to be in snake_case and to have a description that is at least 3 words long.
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class OptionalOption
{
public function __construct(
public string $name,
public string $description,
public mixed $default = null,
public ?string $format = null
) {
$this->validateInputs();
}
private function validateInputs(): void
{
if (! preg_match('/^[a-z][a-z0-9_]*$/', $this->name)) {
throw new InvalidArgumentException("Option name '{$this->name}' must be snake_case and start with a letter");
}
if (empty($this->description) || str_word_count($this->description) < 2) {
throw new InvalidArgumentException('Description must be meaningful with at least 2 words');
}
}
}
// ...
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class RequiredOption
{
public function __construct(
public string $name,
public string $description,
public ?string $model = null,
public ?string $format = null,
) {
$this->validateInputs();
}
private function validateInputs(): void
{
if (! preg_match('/^[a-z][a-z0-9_]*$/', $this->name)) {
throw new InvalidArgumentException("Option name '{$this->name}' must be snake_case and start with a letter");
}
if (empty($this->description) || str_word_count($this->description) < 2) {
throw new InvalidArgumentException('Description must be meaningful with at least 2 words');
}
}
}
// ...
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class FlagOption
{
public function __construct(
public string $name,
public string $description
) {
$this->validateInputs();
}
private function validateInputs(): void
{
if (! preg_match('/^[a-z][a-z0-9_-]*$/', $this->name)) {
throw new InvalidArgumentException("Flag name '{$this->name}' must be kebab-case or snake_case and start with a letter");
}
if (empty($this->description) || str_word_count($this->description) < 2) {
throw new InvalidArgumentException('Description must be meaningful with at least 2 words');
}
}
}
We should also have an attribute that shows that command is "enhanced".
#[Attribute(Attribute::TARGET_CLASS)]
class EnhancedCommand
{
public function __construct(
public string $namespace,
public string $description,
) {
$this->validateInputs();
}
private function validateInputs(): void
{
if (empty($this->description) || str_word_count($this->description) < 3) {
throw new InvalidArgumentException('Description must be a meaningful sentence with at least 3 words');
}
}
}
We will add more attributes later on. Now, we can use these option attributes to validate data and to generate command signature.
abstract class BaseEnhancedCommand extends Command
{
protected array $commandMetadata = [];
public function __construct()
{
$this->extractMetadataFromAttributes();
$this->generateSignatureFromAttributes();
parent::__construct();
}
private function extractMetadataFromAttributes(): void
{
$reflection = new ReflectionClass($this);
$attributes = $reflection->getAttributes();
foreach ($attributes as $attribute) {
$instance = $attribute->newInstance();
$className = class_basename($instance);
if (! isset($this->commandMetadata[$className])) {
$this->commandMetadata[$className] = [];
}
$this->commandMetadata[$className][] = $instance;
}
}
private function generateSignatureFromAttributes(): void
{
$enhancedCommand = $this->getEnhancedCommandAttribute();
if (! $enhancedCommand) {
throw new RuntimeException('EnhancedCommand attribute is required');
}
$signatureParts = [$enhancedCommand->namespace];
foreach ($this->getRequiredOptions() as $option) {
$signatureParts[] = " {--{$option->name}= : {$option->description}}";
}
foreach ($this->getOptionalOptions() as $option) {
$defaultText = $option->default !== null ? " (default: {$option->default})" : '';
$signatureParts[] = " {--{$option->name}= : {$option->description}{$defaultText}}";
}
foreach ($this->getFlagOptions() as $flag) {
$signatureParts[] = " {--{$flag->name} : {$flag->description}}";
}
$existingFlags = $this->getFlagOptions();
$existingFlagNames = array_map(fn($flag) => $flag->name, $existingFlags);
$this->signature = implode("\n", $signatureParts);
$this->description = $enhancedCommand->description;
}
/** Helper methods */
private function getEnhancedCommandAttribute(): ?EnhancedCommand
{
return $this->commandMetadata['EnhancedCommand'][0] ?? null;
}
private function getRequiredOptions(): array
{
return $this->commandMetadata['RequiredOption'] ?? [];
}
private function getOptionalOptions(): array
{
return $this->commandMetadata['OptionalOption'] ?? [];
}
private function getFlagOptions(): array
{
return $this->commandMetadata['FlagOption'] ?? [];
}
Now we have a signature and a knowledge of all options and flags. Validating inputs is something that all commands should implement, so creating an interface with a validateInputs() method makes sense.
interface EnhancedCommandInterface
{
public function validateInputs(): bool;
}
// ...
abstract class BaseEnhancedCommand extends Command implements EnhancedCommandInterface
{
// ...
public function validateInputs(): bool
{
$requiredOptions = $this->getRequiredOptions();
foreach ($requiredOptions as $option) {
if (! $this->validateRequiredOption($option)) {
return false;
}
}
return true;
}
}
So now if we have a BackfillPostsTableCommand that extends BaseEnhancedCommand and it has an option of user_id and a flag force, the command will look like this:
#[EnhancedCommand(namespace: 'backfill:posts-table', description: 'Command used to backfill the posts table with data.')]
#[RequiredOption(name: 'user_id', description: 'The ID of the user whose posts need to be backfilled.')]
#[FlagOption(name: 'force', description: 'Force the backfill operation even if it has been done before.')]
class BackfillPostsTable extends BaseEnhancedCommand
With this setup, we can start handling the problems defined earlier.
Enforcing implementation of the getHelp() method
Since we already have an interface, it is obvious that we need getHelp() in it. A small problem is that getHelp() already exists in the Command class from Laravel, but that is easily fixed by creating a wrapper around it:
interface EnhancedCommandInterface
{
public function validateInputs(): bool;
public function getCommandHelp(): string;
}
// ...
abstract class BaseEnhancedCommand extends Command implements EnhancedCommandInterface
{
// ...
public function getHelp(): string
{
return $this->getCommandHelp();
}
}
Now, all classes that are extending the BaseEnhancedCommand class must implement getCommandHelp(), and once --help is used, it will trigger the getHelp() method.
Use project hierarchy
Here we need to make sure that, if it makes sense for a use case of the command, the command should be possible to use on tenant, project, and user level. Several validation rules are needed here. First of all, it only makes sense to pass IDs of projects or IDs of users. Secondly, we need to make sure that projects or users exist in our database (tenant validation is handled by spatie's multitenancy package that we are using, so I will skip that). This can be done using
private function validateRequiredOption($option): bool
{
if (empty($this->option($option->name))) {
$this->error("Required option --{$option->name} is missing.");
return false;
}
if ($option->format && ! $this->validateFormat($this->option($option->name), $option->format)) {
$this->error("Option --{$option->name} must be in format: {$option->format}");
return false;
}
if (str_contains($option->name, '_ids') || str_contains($option->name, '_id')) {
$modelName = str_replace(['_ids', '_id'], '', $option->name);
// getModelClassFromName() is a helper function. Its implementation varies from project to project
$modelClass = $option->model ?? getModelClassFromName($modelName);
$expectedIdCount = count(explode(',', $this->option($option->name)));
$actualIdCount = $modelClass::whereIn('id', explode(',', $this->option($option->name)))->count();
if ($expectedIdCount !== $actualIdCount) {
$this->error("One or more IDs provided for --{$option->name} and model {$modelClass} do not exist.");
return false;
}
}
return true;
}
Since we want to make sure only one option is given, we can create a MutuallyExclusive attribute:
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class MutuallyExclusive
{
public function __construct(
public array $options,
public string $errorMessage = 'Only one of the specified options can be provided at a time'
) {
$this->validateInputs();
}
private function validateInputs(): void
{
if (count($this->options) < 2) {
throw new InvalidArgumentException('MutuallyExclusive requires at least 2 options');
}
foreach ($this->options as $option) {
if (! is_string($option) || empty($option)) {
throw new InvalidArgumentException('All options must be non-empty strings');
}
}
}
}
Then, extend the validation in BaseEnhancedCommand:
public function validateInputs(): bool
{
$requiredOptions = $this->getRequiredOptions();
foreach ($requiredOptions as $option) {
if (! $this->validateRequiredOption($option)) {
return false;
}
}
$mutuallyExclusiveGroups = $this->getMutuallyExclusiveGroups();
foreach ($mutuallyExclusiveGroups as $group) {
if (! $this->validateMutuallyExclusiveGroup($group)) {
return false;
}
}
return true;
}
// ...
private function getMutuallyExclusiveGroups(): array
{
return $this->commandMetadata['MutuallyExclusive'] ?? [];
}
private function validateMutuallyExclusiveGroup($group): bool
{
$providedOptions = collect($group->options)
->filter(fn($option) => ! is_null($this->option($option)))
->count();
if ($providedOptions > 1) {
$options = implode(', ', array_map(fn($opt) => "--{$opt}", $group->options));
$this->error($group->errorMessage ?: "Only one of [{$options}] can be provided.");
return false;
}
return true;
}
We can use it like this in our commands:
#[EnhancedCommand(namespace: 'reparse:data', description: 'Reparse users data.')]
#[MutuallyExclusive(options: ['project_ids', 'user_ids'])]
class ReparseData extends BaseEnhancedCommand
But since this group will often be used, we can create a UseProjectHierarchy attribute that adds this group to the command:
#[Attribute(Attribute::TARGET_CLASS)]
class UseProjectHierarchy
{
}
And add those options when creating the signature:
private function generateSignatureFromAttributes(): void
{
$enhancedCommand = $this->getEnhancedCommandAttribute();
if (! $enhancedCommand) {
throw new RuntimeException('EnhancedCommand attribute is required');
}
if ($this->usesProjectHierarchy()) {
$this->handleProjectHierarchyOptions();
}
$signatureParts = [$enhancedCommand->namespace];
foreach ($this->getRequiredOptions() as $option) {
$signatureParts[] = " {--{$option->name}= : {$option->description}}";
}
foreach ($this->getOptionalOptions() as $option) {
$defaultText = $option->default !== null ? " (default: {$option->default})" : '';
$signatureParts[] = " {--{$option->name}= : {$option->description}{$defaultText}}";
}
foreach ($this->getFlagOptions() as $flag) {
$signatureParts[] = " {--{$flag->name} : {$flag->description}}";
}
$this->signature = implode("\n", $signatureParts);
$this->description = $enhancedCommand->description;
}
private function usesProjectHierarchy(): bool
{
return isset($this->commandMetadata['UseProjectHierarchy']);
}
private function handleProjectHierarchyOptions(): void
{
$this->commandMetadata['RequiredOption'][] = (object) [
'name' => 'tenant',
'description' => 'The tenant identifier',
'format' => null,
'default' => null
];
$this->commandMetadata['OptionalOption'][] = (object) [
'name' => 'project_ids',
'description' => 'Comma-separated list of project IDs',
'format' => null,
'default' => null
];
$this->commandMetadata['OptionalOption'][] = (object) [
'name' => 'user_ids',
'description' => 'Comma-separated list of user IDs',
'format' => null,
'default' => null
];
$this->commandMetadata['MutuallyExclusive'][] = (object) [
'options' => ['project_ids', 'user_ids'],
'errorMessage' => 'Only one of --project_ids or --user_ids can be provided.'
];
}
After these changes, the ReparseData command looks like this:
#[EnhancedCommand(namespace: 'reparse:data', description: 'Reparse users data.')]
#[UseProjectHierarchy]
class ReparseData extends BaseEnhancedCommand
One time commands
This is easily implemented by adding a OneTimeCommand attribute:
#[Attribute(Attribute::TARGET_CLASS)]
class OneTimeCommand
{
}
Then, in the handle() methods of our Enhanced commands we can call a prepareCommand() method:
#[EnhancedCommand(namespace: 'reparse:data', description: 'Reparse users data.')]
#[UseProjectHierarchy]
#[OneTimeCommand]
class ReparseData extends BaseEnhancedCommand
{
public function validateInputs(): bool
{
if (! parent::validateInputs()) {
return false;
}
// Add any custom validation here
// Return false for validation failures, true for success
return true;
}
public function getCommandHelp(): string
{
// This method returns the formatted help text for the command.
}
public function handle(): int
{
$this->prepareCommand();
// TODO: Implement your command logic here
return Command::SUCCESS;
}
}
And we can place prepareCommand() in BaseEnhancedCommand:
public function prepareCommand(): void
{
$validated = $this->validateInputs();
if (! $validated) {
exit();
}
$this->checkIfNeedsToBeApproved();
}
protected function checkIfNeedsToBeApproved(): void
{
if (! isset($this->commandMetadata['OneTimeCommand'])) {
return;
}
$answer = $this->ask(
'This command is marked as a one-time command and requires approval before execution. Do you want to proceed? (yes/no)',
'no'
);
$answer = strtolower($answer);
if (! in_array($answer, ['yes', 'y'])) {
$this->info('Command execution aborted by user.');
exit;
}
}
This is a simple example that shows how we can improve commands with a one-line change.
Conclusion
These are just some examples of how attributes can be used with artisan commands. There are many other possible attributes that can be helpful, like DateTimeRangeOption which can be used to validate dates, or UseTenant which can be used to add Spatie's multitenancy package's UseTenant trait to the command. Implementing attributes like this leads to less code duplication, fewer bugs, and faster and simpler development. And it looks a lot better.