Support the ongoing development of Laravel.io →

Streamlining Laravel Development with Domain-Aware Artisan Commands

28 Nov, 2023 6 min read 207 views

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"
  • {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.

Last updated 11 months ago.

driesvints, mlambertz liked this article

2
Like this article? Let the author know and give them a clap!
bcryp7 (Alberto Rosas) Hi, I'm Alberto. As a Senior Software Engineer and Consultant.

Other articles you might like

November 11th 2024

🍣 Sushi — Your Eloquent model driver for other data sources

In Laravel projects, we usually store data in databases, create tables, and run migrations. But not...

Read article
April 24th 2024

Find Open-Source Laravel/PHP Projects to Contribute to

Introduction I love open-source software. I love the idea of being able to contribute to a project t...

Read article
November 4th 2024

Laravel Under The Hood - A Little Bit of Macros

Hello 👋 How often have you wished for a method that doesn't exist on collections or string helpers?...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.