Streamlining Laravel Development with Domain-Aware Artisan Commands
Welcome to a concise guide on leveraging custom commands to generate files in a specific directory effectively. This custom command is tailored to work domain-specifically, making your life much easier when generating domain-based files. Let's dive right into the world of domain-aware Artisan commands!
Introduction
The reason behind this approach was the fact that I required somewhat like a Domain-Driven design for the project but without going into the struggles of maintaining such architecture. I wanted to protect the ability to upgrade Laravel versions, the ease of maintenance, and onboard new members of the team without the hassle.
I then remembered I once used a Spatie package (as we all do), specifically the Multitenancy package which gives you access to a command to run Artisan commands per tenant, so I thought it could be nice to use part of their approach to adjust were I want my artisan files to be generated.
Command class
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use function Laravel\Prompts\select;
use Illuminate\Support\Facades\Artisan;
class DomainArtisanCommand extends Command
{
public const DOMAINS = [
self::APP_DOMAIN => [],
self::TENANT_DOMAIN => [
'api',
'dashboard',
],
self::STAFF_DOMAIN => [],
self::CLIENT_DOMAIN => [],
];
public const APP_DOMAIN = 'app';
public const CLIENT_DOMAIN = 'client';
public const STAFF_DOMAIN = 'staff';
public const TENANT_DOMAIN = 'tenant';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:artisan
{artisanCommand : The Artisan command to run within "quotes"}
{domain? : The domain to run the command for (optional)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Run Artisan commands within a specific domain context.';
/**
* Execute the console command.
*
* @return int
*/
public function handle() : int
{
$domain = $this->retrieveDomain();
$artisanCommand = $this->argument('artisanCommand') ?: $this->askArtisanCommand($domain);
if ($this->isDomainSpecificCommand($artisanCommand) &&
$domain !== self::APP_DOMAIN
) {
$artisanCommand = $this->prefixCommandWithPath($artisanCommand, $domain);
}
info("Running artisan command: $artisanCommand");
Artisan::call($artisanCommand, [], $this->getOutput());
info('Artisan command completed successfully!');
return 0;
}
protected function isDomainSpecificCommand($artisanCommand) : bool
{
return in_array(
explode(' ', $artisanCommand)[0],
$this->getCommandsThatGenerateFilesSpecificToDomain()
);
}
protected function prefixCommandWithPath($artisanCommand, $domain) : string
{
$module = $this->retrieveModule($domain);
$modulePathSegment = $module ? Str::studly($module) . '/' : '';
$commandName = explode(' ', $artisanCommand)[0];
$fileFromCommand = explode(' ', $artisanCommand)[1] ?? '';
$path = $this->getDomainPath($domain) . '/' . $modulePathSegment . $this->getPathByArtisanCommand($commandName);
return "{$commandName} {$path}/{$fileFromCommand}";
}
protected function getDomainPath($domain) : string
{
return strtolower($domain) === self::APP_DOMAIN ?
"App" :
"App/Domains/{$domain}";
}
private function retrieveModule(string $domain): string
{
$modules = $this->getModulesPerDomain($domain);
if (empty($modules)) {
return '';
}
return select(
"Select a module to run this command for within the {$domain} domain:",
$modules,
0
);
}
private function getModulesPerDomain(string $domain): array
{
return self::DOMAINS[strtolower($domain)];
}
protected function askArtisanCommand($domain)
{
return $this->ask("Which artisan command do you want to run for the {$domain} Domain?");
}
public function getCommandsThatGenerateFilesSpecificToDomain() : array
{
return [
'make:controller',
'make:command',
'make:event',
'make:exception',
'make:job',
'make:listener',
'make:mail',
'make:middleware',
'make:model',
'make:notification',
'make:observer',
'make:policy',
'make:provider',
'make:request',
'make:resource',
'make:rule',
];
}
private function retrieveDomain(): string
{
if (!$this->argument('domain') || !in_array(
$this->argument('domain'),
array_keys($this->getDomainsWithPath())
)) {
$domain = select(
'Select a domain to run this command for:',
array_keys($this->getDomainsWithPath()),
0,
);
} else {
$domain = $this->argument('domain');
}
return Str::studly(Str::camel($domain));
}
public function getDomainsWithPath(): array
{
return [
'client' => app_path('Domains/Client'),
'staff' => app_path('Domains/Staff'),
'tenant' => app_path('Domains/Tenant'),
];
}
public function getPathByArtisanCommand($command): string
{
return match ($command) {
'make:controller' => 'Http/Controllers',
'make:command' => 'Console/Commands',
'make:event' => 'Events',
'make:exception' => 'Exceptions',
'make:job' => 'Jobs',
'make:listener' => 'Listeners',
'make:mail' => 'Mail',
'make:middleware' => 'Http/Middleware',
'make:model' => 'Models',
'make:notification' => 'Notifications',
'make:observer' => 'Observers',
'make:policy' => 'Policies',
'make:provider' => 'Providers',
'make:request' => 'Http/Requests',
'make:resource' => 'Http/Resources',
'make:rule' => 'Rules',
default => app_path(),
};
}
}
Understanding the Command
The Domain-Aware Artisan command seamlessly integrates into Laravel's default Artisan commands while accommodating our unique project structure. Its primary function is to generate files within specified domains (App, Client, Staff, Tenant for example), ensuring a clean separation of concerns without deviating too much from the Laravel norm.
Basic Syntax
php artisan app:domain {artisanCommand} {domain?}
Parameters:
- {artisanCommand}: Wrap the desired Laravel Artisan command in quotes.
- Examples:
- "make:controller SomeController"
- "make:observer Somewhere/SomeObserver"
- Examples:
- {domain?} (Optional): Specify the domain you're targeting: app, client, staff, or tenant. If you skip this, the command will prompt you to choose one interactively.
Examples:
To make a controller within the Staff domain:
php artisan app:domain "make:controller ClientController" staff
To generate a request within the Tenant domain:
php artisan app:domain "make:request CompanyRequest" tenant
Behind the Scenes
When the app:domain
command is run, it performs the following tasks:
Domain Determination
If you don't provide a domain, it will prompt you to select one. Currently, we have 3 confirmed domains: App (is the standard App namespace so we're not counting it), Client, Staff, and Tenant.
Path Resolution
It determines the correct path based on the provided domain and the Artisan command's nature. For example, if you're generating a controller within the Staff domain, it will resolve to app/Domains/Staff/Http/Controllers
Artisan Delegation
It then delegates the command to Laravel's Artisan, ensuring the file is generated at the right place, keeping our domain structure intact.
To be fair, the "App" domain shouldn't be included in this command since you can just use Artisan to do this, but I added it just as a precausion in case it's needed.
Modules per Domain
The app:domain
command is designed to work with all domains. However, some domains have modules and others don't.
For example:
Tenant Domain has modules, so you can generate files within a specific module:
- API
- Dashboard
So if you want to generate a controller within the API module, you can do so by selecting the module you want to generate the file in.:
Select a module to run this command for within the {$domain} domain:
[0] api
[1] dashboard
We're using Jess Archer's awesome package Laravel Prompts to give a nice format to our commands.
This will generate the controller within the app/Domains/Tenant/Api/Http/Controllers
directory.
You can add Modules to a Domain by adding the lowercase module name to the DOMAINS constant array in the DomainArtisanCommand class (this could be better handled, just needed a quick way to solve the module situation):
const DOMAINS = [
self::APP_DOMAIN => [],
self::TENANT_DOMAIN => [
'api',
'dashboard',
],
self::STAFF_DOMAIN => [],
self::CLIENT_DOMAIN => [],
];
Tips
- Always Quote Your Commands: To avoid potential issues, always wrap the actual Laravel Artisan command in quotes.
- Know Your Domains: Familiarize yourself with the domains you are including. This helps to quickly determine where the generated file should reside.
Wrapping Up
And that's about it! The artisan:domain command is here to make our modular approach more streamlined. So next time you need to whip up a new controller, request, or any other file in a specific domain, give artisan:domain a go.Lar
Let's keep our codebase tidy and efficient.
driesvints, mlambertz liked this article