diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2926890..c2fab9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,44 @@
All notable changes to `laravel-ddd` will be documented in this file.
+## [Unreleased]
+### Added
+- Experimental: Ability to configure the Application Layer, to generate domain objects that don't typically belong inside the domain layer.
+ ```php
+ // In config/ddd.php
+ 'application' => [
+ 'path' => 'app/Modules',
+ 'namespace' => 'App\Modules',
+ 'objects' => [
+ 'controller',
+ 'request',
+ 'middleware',
+ ],
+ ],
+ ```
+- Added `ddd:controller` to generate domain-specific controllers in the application layer.
+- Added `ddd:request` to generate domain-spefic requests in the application layer.
+- Added `ddd:middleware` to generate domain-specific middleware in the application layer.
+- Added `ddd:migration` to generate domain migrations.
+- Added `ddd:seeder` to generate domain seeders.
+- Migration folders across domains will be registered and scanned when running `php artisan migrate`, in addition to the standard application `database/migrations` path.
+
+### Changed
+- `ddd:model` now internally extends Laravel's native `make:model` and inherits all standard options:
+ - `--migration|-m`
+ - `--factory|-f`
+ - `--seed|-s`
+ - `--controller --resource --requests|-crR`
+ - `--policy`
+ - `-mfsc`
+ - `--all|-a`
+ - `--pivot|-p`
+- `ddd:cache` is now `ddd:optimize` (`ddd:cache` is still available as an alias).
+- For Laravel 11.27.1+, the framework's `optimize` and `optimize:clear` commands will automatically invoke `ddd:optimize` and `ddd:clear` respectively.
+
+### Deprecated
+- Domain base models are no longer required by default, and `config('ddd.base_model')` is now `null` by default.
+
## [1.1.2] - 2024-09-02
### Fixed
- During domain factory autoloading, ensure that `guessFactoryNamesUsing` returns a string when a domain factory is resolved.
diff --git a/README.md b/README.md
index fd1c833..beb7412 100644
--- a/README.md
+++ b/README.md
@@ -19,11 +19,26 @@ You may initialize the package using the `ddd:install` artisan command. This wil
php artisan ddd:install
```
+### Peer Dependencies
+The following additional packages are suggested (but not required) while working with this package.
+
+Data Transfer Objects: [spatie/laravel-data](https://github.com/spatie/laravel-data)
+```bash
+composer require spatie/laravel-data
+```
+
+Actions: [lorisleiva/laravel-actions](https://github.com/lorisleiva/laravel-actions)
+```bash
+composer require lorisleiva/laravel-actions
+```
+The default DTO and Action stubs of this package reference classes from these packages. If this doesn't apply to your application, you may customize the stubs accordingly.
+
### Deployment
-In production, run `ddd:cache` during the deployment process to [optimize autoloading](#autoloading-in-production).
+In production, run `ddd:optimize` during the deployment process to [optimize autoloading](#autoloading-in-production).
```bash
-php artisan ddd:cache
+php artisan ddd:optimize
```
+Since Laravel 11.27.1, `php artisan optimize` automatically invokes `ddd:optimize`. If you already run `optimize` in production, a separate `ddd:optimize` is no longer necessary.
### Version Compatibility
Laravel | LaravelDDD | |
@@ -53,55 +68,40 @@ php artisan ddd:{object} {name}
## Available Commands
### Generators
-The following generators are currently available, shown using short-hand syntax:
-```bash
-# Generate a domain model
-php artisan ddd:model Invoicing:Invoice
-
-# Generate a domain model with factory
-php artisan ddd:model Invoicing:Invoice -f
-php artisan ddd:model Invoicing:Invoice --factory
-
-# Generate a domain factory
-php artisan ddd:factory Invoicing:InvoiceFactory
-php artisan ddd:factory Invoicing:InvoiceFactory --model=Invoice # optionally specifying the model
-
-# Generate a data transfer object
-php artisan ddd:dto Invoicing:LineItemPayload
-
-# Generates a value object
-php artisan ddd:value Shared:DollarAmount
-
-# Generates a view model
-php artisan ddd:view-model Invoicing:ShowInvoiceViewModel
-
-# Generates an action
-php artisan ddd:action Invoicing:SendInvoiceToCustomer
-
-# Extended Commands
-# These extend Laravel's respective make:* commands and places the objects into the domain layer
-php artisan ddd:cast Invoicing:MoneyCast
-php artisan ddd:channel Invoicing:InvoiceChannel
-php artisan ddd:command Invoicing:InvoiceDeliver
-php artisan ddd:event Invoicing:PaymentWasReceived
-php artisan ddd:exception Invoicing:InvoiceNotFoundException
-php artisan ddd:job Invoicing:GenerateInvoicePdf
-php artisan ddd:listener Invoicing:HandlePaymentReceived
-php artisan ddd:mail Invoicing:OverduePaymentReminderEmail
-php artisan ddd:notification Invoicing:YourPaymentWasReceived
-php artisan ddd:observer Invoicing:InvoiceObserver
-php artisan ddd:policy Invoicing:InvoicePolicy
-php artisan ddd:provider Invoicing:InvoiceServiceProvider
-php artisan ddd:resource Invoicing:InvoiceResource
-php artisan ddd:rule Invoicing:ValidPaymentMethod
-php artisan ddd:scope Invoicing:ArchivedInvoicesScope
-
-# Laravel 11+ only
-php artisan ddd:class Invoicing:Support/InvoiceBuilder
-php artisan ddd:enum Customer:CustomerType
-php artisan ddd:interface Customer:Contracts/Invoiceable
-php artisan ddd:trait Customer:Concerns/HasInvoices
-```
+The following generators are currently available:
+| Command | Description | Usage |
+|---|---|---|
+| `ddd:model` | Generate a domain model | `php artisan ddd:model Invoicing:Invoice`
Options:
`--migration\|-m`
`--factory\|-f`
`--seed\|-s`
`--controller --resource --requests\|-crR`
`--policy`
`-mfsc`
`--all\|-a`
`--pivot\|-p`
|
+| `ddd:factory` | Generate a domain factory | `php artisan ddd:factory Invoicing:InvoiceFactory` |
+| `ddd:dto` | Generate a data transfer object | `php artisan ddd:dto Invoicing:LineItemPayload` |
+| `ddd:value` | Generate a value object | `php artisan ddd:value Shared:DollarAmount` |
+| `ddd:view-model` | Generate a view model | `php artisan ddd:view-model Invoicing:ShowInvoiceViewModel` |
+| `ddd:action` | Generate an action | `php artisan ddd:action Invoicing:SendInvoiceToCustomer` |
+| `ddd:cast` | Generate a cast | `php artisan ddd:cast Invoicing:MoneyCast` |
+| `ddd:channel` | Generate a channel | `php artisan ddd:channel Invoicing:InvoiceChannel` |
+| `ddd:command` | Generate a command | `php artisan ddd:command Invoicing:InvoiceDeliver` |
+| `ddd:controller` | Generate a controller | `php artisan ddd:controller Invoicing:InvoiceController`
Options: inherits options from *make:controller* |
+| `ddd:event` | Generate an event | `php artisan ddd:event Invoicing:PaymentWasReceived` |
+| `ddd:exception` | Generate an exception | `php artisan ddd:exception Invoicing:InvoiceNotFoundException` |
+| `ddd:job` | Generate a job | `php artisan ddd:job Invoicing:GenerateInvoicePdf` |
+| `ddd:listener` | Generate a listener | `php artisan ddd:listener Invoicing:HandlePaymentReceived` |
+| `ddd:mail` | Generate a mail | `php artisan ddd:mail Invoicing:OverduePaymentReminderEmail` |
+| `ddd:middleware` | Generate a middleware | `php artisan ddd:middleware Invoicing:VerifiedCustomerMiddleware` |
+| `ddd:migration` | Generate a migration | `php artisan ddd:migration Invoicing:CreateInvoicesTable` |
+| `ddd:notification` | Generate a notification | `php artisan ddd:notification Invoicing:YourPaymentWasReceived` |
+| `ddd:observer` | Generate an observer | `php artisan ddd:observer Invoicing:InvoiceObserver` |
+| `ddd:policy` | Generate a policy | `php artisan ddd:policy Invoicing:InvoicePolicy` |
+| `ddd:provider` | Generate a provider | `php artisan ddd:provider Invoicing:InvoiceServiceProvider` |
+| `ddd:resource` | Generate a resource | `php artisan ddd:resource Invoicing:InvoiceResource` |
+| `ddd:rule` | Generate a rule | `php artisan ddd:rule Invoicing:ValidPaymentMethod` |
+| `ddd:request` | Generate a form request | `php artisan ddd:request Invoicing:StoreInvoiceRequest` |
+| `ddd:scope` | Generate a scope | `php artisan ddd:scope Invoicing:ArchivedInvoicesScope` |
+| `ddd:seeder` | Generate a seeder | `php artisan ddd:seeder Invoicing:InvoiceSeeder` |
+| `ddd:class` | Generate a class (Laravel 11+) | `php artisan ddd:class Invoicing:Support/InvoiceBuilder` |
+| `ddd:enum` | Generate an enum (Laravel 11+) | `php artisan ddd:enum Customer:CustomerType` |
+| `ddd:interface` | Generate an interface (Laravel 11+) | `php artisan ddd:interface Customer:Contracts/Invoiceable` |
+| `ddd:trait` | Generate a trait (Laravel 11+) | `php artisan ddd:trait Customer:Concerns/HasInvoices` |
+
Generated objects will be placed in the appropriate domain namespace as specified by `ddd.namespaces.*` in the [config file](#config-file).
### Other Commands
@@ -110,13 +110,47 @@ Generated objects will be placed in the appropriate domain namespace as specifie
php artisan ddd:list
# Cache domain manifests (used for autoloading)
-php artisan ddd:cache
+php artisan ddd:optimize
# Clear the domain cache
php artisan ddd:clear
```
## Advanced Usage
+### Application Layer (since 1.2)
+Some objects interact with the domain layer, but are not part of the domain layer themselves. By default, these include: `controller`, `request`, `middleware`. You may customize the path, namespace, and which `ddd:*` objects belong in the application layer.
+```php
+// In config/ddd.php
+'application' => [
+ 'path' => 'app/Modules',
+ 'namespace' => 'App\Modules',
+ 'objects' => [
+ 'controller',
+ 'request',
+ 'middleware',
+ ],
+],
+```
+The configuration above will result in the following:
+```bash
+ddd:model Invoicing:Invoice --controller --resource --requests
+```
+Output:
+```
+├─ app
+| └─ Modules
+│ └─ Invoicing
+│ ├─ Controllers
+│ │ └─ InvoiceController.php
+│ └─ Requests
+│ ├─ StoreInvoiceRequest.php
+│ └─ UpdateInvoiceRequest.php
+├─ src/Domain
+ └─ Invoicing
+ └─ Models
+ └─ Invoice.php
+```
+
### Nested Objects
For any `ddd:*` generator command, nested objects can be specified with forward slashes.
```bash
@@ -194,6 +228,7 @@ Autoloading behaviour can be configured with the `ddd.autoload` configuration op
'commands' => true,
'policies' => true,
'factories' => true,
+ 'migrations' => true,
],
```
### Service Providers
@@ -210,14 +245,18 @@ When `ddd.autoload.factories` is enabled, the package will register a custom fac
If your application implements its own factory discovery using `Factory::guessFactoryNamesUsing()`, you should set `ddd.autoload.factories` to `false` to ensure it is not overridden.
+### Migrations
+When `ddd.autoload.migrations` is enabled, paths within the domain layer matching the configured `ddd.namespaces.migration` namespace will be auto-registered as a database migration path and recognized by `php artisan migrate`.
+
### Ignoring Paths During Autoloading
-To specify folders or paths that should be skipped during autoloading discovery, add them to the `ddd.autoload_ignore` configuration option. By default, the `Tests` and `Migrations` folders are ignored.
+To specify folders or paths that should be skipped during autoloading class discovery, add them to the `ddd.autoload_ignore` configuration option. By default, the `Tests` and `Migrations` folders are ignored.
```php
'autoload_ignore' => [
'Tests',
'Database/Migrations',
],
```
+Note that ignoring folders only applies to class-based autoloading: Service Providers, Console Commands, Policies, and Factories.
Paths specified here are relative to the root of each domain. e.g., `src/Domain/Invoicing/{path-to-ignore}`. If more advanced filtering is needed, a callback can be registered using `DDD::filterAutoloadPathsUsing(callback $filter)` in your AppServiceProvider's boot method:
```php
@@ -240,13 +279,16 @@ You may disable autoloading by setting the respective autoload options to `false
// 'commands' => true,
// 'policies' => true,
// 'factories' => true,
+// 'migrations' => true,
// ],
```
## Autoloading in Production
-In production, you should cache the autoload manifests using the `ddd:cache` command as part of your application's deployment process. This will speed up the auto-discovery and registration of domain providers and commands. The `ddd:clear` command may be used to clear the cache if needed.
+In production, you should cache the autoload manifests using the `ddd:optimize` command as part of your application's deployment process. This will speed up the auto-discovery and registration of domain providers and commands. The `ddd:clear` command may be used to clear the cache if needed.
+
+> **Note**: Since Laravel 11.27.1, the framework's `optimize` and `optimize:clear` commands will automatically invoke `ddd:optimize` and `ddd:clear` respectively.
@@ -278,12 +320,34 @@ return [
/*
|--------------------------------------------------------------------------
- | Domain Object Namespaces
+ | Application Layer
|--------------------------------------------------------------------------
|
- | This value contains the default namespaces of generated domain
- | objects relative to the domain namespace of which the object
- | belongs to.
+ | Configure objects that belong in the application layer.
+ |
+ | e.g., App\Modules\Invoicing\Controllers\*
+ | App\Modules\Invoicing\Requests\*
+ |
+ */
+ 'application' => [
+ 'path' => 'app/Modules',
+ 'namespace' => 'App\Modules',
+
+ // Specify which ddd:* objects belong in the application layer
+ 'objects' => [
+ 'controller',
+ 'request',
+ 'middleware',
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Generator Object Namespaces
+ |--------------------------------------------------------------------------
+ |
+ | This array maps the default relative namespaces of generated objects
+ | relative to their domain's root namespace.
|
| e.g., Domain\Invoicing\Models\*
| Domain\Invoicing\Data\*
@@ -302,6 +366,7 @@ return [
'class' => '',
'channel' => 'Channels',
'command' => 'Commands',
+ 'controller' => 'Controllers',
'enum' => 'Enums',
'event' => 'Events',
'exception' => 'Exceptions',
@@ -310,13 +375,17 @@ return [
'job' => 'Jobs',
'listener' => 'Listeners',
'mail' => 'Mail',
+ 'middleware' => 'Middleware',
+ 'migration' => 'Database\Migrations',
'notification' => 'Notifications',
'observer' => 'Observers',
'policy' => 'Policies',
'provider' => 'Providers',
'resource' => 'Resources',
+ 'request' => 'Requests',
'rule' => 'Rules',
'scope' => 'Scopes',
+ 'seeder' => 'Database\Seeders',
'trait' => '',
],
@@ -325,12 +394,11 @@ return [
| Base Model
|--------------------------------------------------------------------------
|
- | The base class which generated domain models should extend. By default,
- | generated domain models will extend `Domain\Shared\Models\BaseModel`,
- | which will be created if it doesn't already exist.
+ | The base model class which generated domain models should extend. If
+ | set to null, the generated models will extend Laravel's default.
|
*/
- 'base_model' => 'Domain\Shared\Models\BaseModel',
+ 'base_model' => null,
/*
|--------------------------------------------------------------------------
@@ -374,34 +442,16 @@ return [
| Autoloading
|--------------------------------------------------------------------------
|
- | Configure whether domain providers, commands, policies, and factories
- | should be auto-discovered and registered.
+ | Configure whether domain providers, commands, policies, factories,
+ | and migrations should be auto-discovered and registered.
|
*/
'autoload' => [
- /**
- * When enabled, any class within the domain layer extending `Illuminate\Support\ServiceProvider`
- * will be auto-registered as a service provider
- */
'providers' => true,
-
- /**
- * When enabled, any class within the domain layer extending `Illuminate\Console\Command`
- * will be auto-registered as a command when running in console.
- */
'commands' => true,
-
- /**
- * When enabled, the package will register a custom policy discovery callback to resolve policy names
- * for domain models, and fallback to Laravel's default for all other cases.
- */
'policies' => true,
-
- /**
- * When enabled, the package will register a custom factory discovery callback to resolve factory names
- * for domain models, and fallback to Laravel's default for all other cases.
- */
'factories' => true,
+ 'migrations' => true,
],
/*
@@ -415,7 +465,7 @@ return [
| e.g., src/Domain/Invoicing/
|
| If more advanced filtering is needed, a callback can be registered
- | using the `DDD::filterAutoloadPathsUsing(callback $filter)` in
+ | using `DDD::filterAutoloadPathsUsing(callback $filter)` in
| the AppServiceProvider's boot method.
|
*/
diff --git a/composer.json b/composer.json
index 243d503..c7fa76b 100644
--- a/composer.json
+++ b/composer.json
@@ -33,7 +33,8 @@
"pestphp/pest-plugin-laravel": "^2.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-deprecation-rules": "^1.0",
- "phpstan/phpstan-phpunit": "^1.0"
+ "phpstan/phpstan-phpunit": "^1.0",
+ "spatie/laravel-data": "^4.10"
},
"autoload": {
"psr-4": {
diff --git a/config/ddd.php b/config/ddd.php
index 66d8901..dfa4eda 100644
--- a/config/ddd.php
+++ b/config/ddd.php
@@ -24,12 +24,34 @@
/*
|--------------------------------------------------------------------------
- | Domain Object Namespaces
+ | Application Layer
|--------------------------------------------------------------------------
|
- | This value contains the default namespaces of generated domain
- | objects relative to the domain namespace of which the object
- | belongs to.
+ | Configure objects that belong in the application layer.
+ |
+ | e.g., App\Modules\Invoicing\Controllers\*
+ | App\Modules\Invoicing\Requests\*
+ |
+ */
+ 'application' => [
+ 'path' => 'app/Modules',
+ 'namespace' => 'App\Modules',
+
+ // Specify which ddd:* objects belong in the application layer
+ 'objects' => [
+ 'controller',
+ 'request',
+ 'middleware',
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Generator Object Namespaces
+ |--------------------------------------------------------------------------
+ |
+ | This array maps the default relative namespaces of generated objects
+ | relative to their domain's root namespace.
|
| e.g., Domain\Invoicing\Models\*
| Domain\Invoicing\Data\*
@@ -48,6 +70,7 @@
'class' => '',
'channel' => 'Channels',
'command' => 'Commands',
+ 'controller' => 'Controllers',
'enum' => 'Enums',
'event' => 'Events',
'exception' => 'Exceptions',
@@ -56,13 +79,17 @@
'job' => 'Jobs',
'listener' => 'Listeners',
'mail' => 'Mail',
+ 'middleware' => 'Middleware',
+ 'migration' => 'Database\Migrations',
'notification' => 'Notifications',
'observer' => 'Observers',
'policy' => 'Policies',
'provider' => 'Providers',
'resource' => 'Resources',
+ 'request' => 'Requests',
'rule' => 'Rules',
'scope' => 'Scopes',
+ 'seeder' => 'Database\Seeders',
'trait' => '',
],
@@ -71,12 +98,11 @@
| Base Model
|--------------------------------------------------------------------------
|
- | The base class which generated domain models should extend. By default,
- | generated domain models will extend `Domain\Shared\Models\BaseModel`,
- | which will be created if it doesn't already exist.
+ | The base model class which generated domain models should extend. If
+ | set to null, the generated models will extend Laravel's default.
|
*/
- 'base_model' => 'Domain\Shared\Models\BaseModel',
+ 'base_model' => null,
/*
|--------------------------------------------------------------------------
@@ -120,34 +146,16 @@
| Autoloading
|--------------------------------------------------------------------------
|
- | Configure whether domain providers, commands, policies, and factories
- | should be auto-discovered and registered.
+ | Configure whether domain providers, commands, policies, factories,
+ | and migrations should be auto-discovered and registered.
|
*/
'autoload' => [
- /**
- * When enabled, any class within the domain layer extending `Illuminate\Support\ServiceProvider`
- * will be auto-registered as a service provider
- */
'providers' => true,
-
- /**
- * When enabled, any class within the domain layer extending `Illuminate\Console\Command`
- * will be auto-registered as a command when running in console.
- */
'commands' => true,
-
- /**
- * When enabled, the package will register a custom policy discovery callback to resolve policy names
- * for domain models, and fallback to Laravel's default for all other cases.
- */
'policies' => true,
-
- /**
- * When enabled, the package will register a custom factory discovery callback to resolve factory names
- * for domain models, and fallback to Laravel's default for all other cases.
- */
'factories' => true,
+ 'migrations' => true,
],
/*
diff --git a/config/ddd.php.stub b/config/ddd.php.stub
index 6c10645..612b1d8 100644
--- a/config/ddd.php.stub
+++ b/config/ddd.php.stub
@@ -22,6 +22,29 @@ return [
*/
'domain_namespace' => {{domain_namespace}},
+ /*
+ |--------------------------------------------------------------------------
+ | Application Layer
+ |--------------------------------------------------------------------------
+ |
+ | Configure objects that belong in the application layer.
+ |
+ | e.g., App\Modules\Invoicing\Controllers\*
+ | App\Modules\Invoicing\Requests\*
+ |
+ */
+ 'application' => [
+ 'path' => 'app/Modules',
+ 'namespace' => 'App\Modules',
+
+ // Specify which ddd:* objects belong in the application layer
+ 'objects' => [
+ 'controller',
+ 'request',
+ 'middleware',
+ ],
+ ],
+
/*
|--------------------------------------------------------------------------
| Domain Object Namespaces
@@ -48,6 +71,7 @@ return [
'class' => '',
'channel' => 'Channels',
'command' => 'Commands',
+ 'controller' => 'Controllers',
'enum' => 'Enums',
'event' => 'Events',
'exception' => 'Exceptions',
@@ -56,13 +80,17 @@ return [
'job' => 'Jobs',
'listener' => 'Listeners',
'mail' => 'Mail',
+ 'middleware' => 'Middleware',
+ 'migration' => 'Database\Migrations',
'notification' => 'Notifications',
'observer' => 'Observers',
'policy' => 'Policies',
'provider' => 'Providers',
'resource' => 'Resources',
+ 'request' => 'Requests',
'rule' => 'Rules',
'scope' => 'Scopes',
+ 'seeder' => 'Database\Seeders',
'trait' => '',
],
@@ -125,29 +153,11 @@ return [
|
*/
'autoload' => [
- /**
- * When enabled, any class within the domain layer extending `Illuminate\Support\ServiceProvider`
- * will be auto-registered as a service provider
- */
'providers' => true,
-
- /**
- * When enabled, any class within the domain layer extending `Illuminate\Console\Command`
- * will be auto-registered as a command when running in console.
- */
'commands' => true,
-
- /**
- * When enabled, the package will register a custom policy discovery callback to resolve policy names
- * for domain models, and fallback to Laravel's default for all other cases.
- */
'policies' => true,
-
- /**
- * When enabled, the package will register a custom factory discovery callback to resolve factory names
- * for domain models, and fallback to Laravel's default for all other cases.
- */
'factories' => true,
+ 'migrations' => true,
],
/*
diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php
deleted file mode 100644
index 4d4fe68..0000000
--- a/src/Commands/CacheCommand.php
+++ /dev/null
@@ -1,24 +0,0 @@
-components->info('Domain providers cached successfully.');
-
- DomainAutoloader::cacheCommands();
-
- $this->components->info('Domain commands cached successfully.');
- }
-}
diff --git a/src/Commands/Concerns/ForwardsToDomainCommands.php b/src/Commands/Concerns/ForwardsToDomainCommands.php
new file mode 100644
index 0000000..6586bd7
--- /dev/null
+++ b/src/Commands/Concerns/ForwardsToDomainCommands.php
@@ -0,0 +1,62 @@
+getNameInput(), '/')
+ ? Str::beforeLast($this->getNameInput(), '/')
+ : null;
+
+ $nameWithSubfolder = $subfolder ? "{$subfolder}/{$arguments['name']}" : $arguments['name'];
+
+ return match ($command) {
+ 'make:request' => $this->runCommand('ddd:request', [
+ ...$arguments,
+ 'name' => $nameWithSubfolder,
+ '--domain' => $this->domain->dotName,
+ ], $this->output),
+
+ 'make:model' => $this->runCommand('ddd:model', [
+ ...$arguments,
+ 'name' => $nameWithSubfolder,
+ '--domain' => $this->domain->dotName,
+ ], $this->output),
+
+ 'make:factory' => $this->runCommand('ddd:factory', [
+ ...$arguments,
+ 'name' => $nameWithSubfolder,
+ '--domain' => $this->domain->dotName,
+ ], $this->output),
+
+ 'make:policy' => $this->runCommand('ddd:policy', [
+ ...$arguments,
+ 'name' => $nameWithSubfolder,
+ '--domain' => $this->domain->dotName,
+ ], $this->output),
+
+ 'make:migration' => $this->runCommand('ddd:migration', [
+ ...$arguments,
+ '--domain' => $this->domain->dotName,
+ ], $this->output),
+
+ 'make:seeder' => $this->runCommand('ddd:seeder', [
+ ...$arguments,
+ 'name' => $nameWithSubfolder,
+ '--domain' => $this->domain->dotName,
+ ], $this->output),
+
+ 'make:controller' => $this->runCommand('ddd:controller', [
+ ...$arguments,
+ 'name' => $nameWithSubfolder,
+ '--domain' => $this->domain->dotName,
+ ], $this->output),
+
+ default => $this->runCommand($command, $arguments, $this->output),
+ };
+ }
+}
diff --git a/src/Commands/Concerns/HandleHooks.php b/src/Commands/Concerns/HandleHooks.php
new file mode 100644
index 0000000..9a1ed27
--- /dev/null
+++ b/src/Commands/Concerns/HandleHooks.php
@@ -0,0 +1,25 @@
+beforeHandle();
+
+ parent::handle();
+
+ $this->afterHandle();
+ }
+}
diff --git a/src/Commands/Concerns/QualifiesDomainModels.php b/src/Commands/Concerns/QualifiesDomainModels.php
new file mode 100644
index 0000000..8e5af0a
--- /dev/null
+++ b/src/Commands/Concerns/QualifiesDomainModels.php
@@ -0,0 +1,17 @@
+domain) {
+ $domainModel = $domain->model($model);
+
+ return $domainModel->fullyQualifiedName;
+ }
+
+ return parent::qualifyModel($model);
+ }
+}
diff --git a/src/Commands/Concerns/ResolvesDomainFromInput.php b/src/Commands/Concerns/ResolvesDomainFromInput.php
index 9f323b6..9192aee 100644
--- a/src/Commands/Concerns/ResolvesDomainFromInput.php
+++ b/src/Commands/Concerns/ResolvesDomainFromInput.php
@@ -10,7 +10,9 @@
trait ResolvesDomainFromInput
{
- use CanPromptForDomain;
+ use CanPromptForDomain,
+ HandleHooks,
+ QualifiesDomainModels;
protected $nameIsAbsolute = false;
@@ -26,7 +28,9 @@ protected function getOptions()
protected function rootNamespace()
{
- return Str::finish(DomainResolver::domainRootNamespace(), '\\');
+ $type = $this->guessObjectType();
+
+ return Str::finish(DomainResolver::resolveRootNamespace($type), '\\');
}
protected function guessObjectType(): string
@@ -36,6 +40,7 @@ protected function guessObjectType(): string
'ddd:base-model' => 'model',
'ddd:value' => 'value_object',
'ddd:dto' => 'data_transfer_object',
+ 'ddd:migration' => 'migration',
default => str($this->name)->after(':')->snake()->toString(),
};
}
@@ -66,7 +71,7 @@ protected function getPath($name)
return parent::getPath($name);
}
- public function handle()
+ protected function beforeHandle()
{
$nameInput = $this->getNameInput();
@@ -106,6 +111,6 @@ public function handle()
$this->input->setArgument('name', $nameInput);
- parent::handle();
+ app('ddd')->captureCommandContext($this, $this->domain, $this->guessObjectType());
}
}
diff --git a/src/Commands/DomainControllerMakeCommand.php b/src/Commands/DomainControllerMakeCommand.php
new file mode 100644
index 0000000..aa2a373
--- /dev/null
+++ b/src/Commands/DomainControllerMakeCommand.php
@@ -0,0 +1,82 @@
+parseModel($this->option('model'));
+
+ if (
+ ! app()->runningUnitTests()
+ && ! class_exists($modelClass)
+ && confirm("A {$modelClass} model does not exist. Do you want to generate it?", default: true)
+ ) {
+ $this->call('make:model', ['name' => $modelClass]);
+ }
+
+ $replace = $this->buildFormRequestReplacements($replace, $modelClass);
+
+ return array_merge($replace, [
+ 'DummyFullModelClass' => $modelClass,
+ '{{ namespacedModel }}' => $modelClass,
+ '{{namespacedModel}}' => $modelClass,
+ 'DummyModelClass' => class_basename($modelClass),
+ '{{ model }}' => class_basename($modelClass),
+ '{{model}}' => class_basename($modelClass),
+ 'DummyModelVariable' => lcfirst(class_basename($modelClass)),
+ '{{ modelVariable }}' => lcfirst(class_basename($modelClass)),
+ '{{modelVariable}}' => lcfirst(class_basename($modelClass)),
+ ]);
+ }
+
+ protected function buildFormRequestReplacements(array $replace, $modelClass)
+ {
+ [$namespace, $storeRequestClass, $updateRequestClass] = [
+ 'Illuminate\\Http',
+ 'Request',
+ 'Request',
+ ];
+
+ if ($this->option('requests')) {
+ $namespace = $this->domain->namespaceFor('request', $this->getNameInput());
+
+ [$storeRequestClass, $updateRequestClass] = $this->generateFormRequests(
+ $modelClass,
+ $storeRequestClass,
+ $updateRequestClass
+ );
+ }
+
+ $namespacedRequests = $namespace.'\\'.$storeRequestClass.';';
+
+ if ($storeRequestClass !== $updateRequestClass) {
+ $namespacedRequests .= PHP_EOL.'use '.$namespace.'\\'.$updateRequestClass.';';
+ }
+
+ return array_merge($replace, [
+ '{{ storeRequest }}' => $storeRequestClass,
+ '{{storeRequest}}' => $storeRequestClass,
+ '{{ updateRequest }}' => $updateRequestClass,
+ '{{updateRequest}}' => $updateRequestClass,
+ '{{ namespacedStoreRequest }}' => $namespace.'\\'.$storeRequestClass,
+ '{{namespacedStoreRequest}}' => $namespace.'\\'.$storeRequestClass,
+ '{{ namespacedUpdateRequest }}' => $namespace.'\\'.$updateRequestClass,
+ '{{namespacedUpdateRequest}}' => $namespace.'\\'.$updateRequestClass,
+ '{{ namespacedRequests }}' => $namespacedRequests,
+ '{{namespacedRequests}}' => $namespacedRequests,
+ ]);
+ }
+}
diff --git a/src/Commands/DomainMiddlewareMakeCommand.php b/src/Commands/DomainMiddlewareMakeCommand.php
new file mode 100644
index 0000000..2c6b2af
--- /dev/null
+++ b/src/Commands/DomainMiddlewareMakeCommand.php
@@ -0,0 +1,13 @@
+argument('name'));
}
- protected function getStub()
+ public function handle()
{
- return $this->resolveStubPath('model.php.stub');
- }
+ $this->beforeHandle();
- protected function preparePlaceholders(): array
- {
- $baseClass = config('ddd.base_model');
- $baseClassName = class_basename($baseClass);
+ $this->createBaseModelIfNeeded();
- return [
- 'extends' => filled($baseClass) ? " extends {$baseClassName}" : '',
- 'baseClassImport' => filled($baseClass) ? "use {$baseClass};" : '',
- ];
+ parent::handle();
+
+ $this->afterHandle();
}
- public function handle()
+ protected function buildClass($name)
{
- $this->createBaseModelIfNeeded();
+ $stub = parent::buildClass($name);
- parent::handle();
+ $replacements = [
+ 'use Illuminate\Database\Eloquent\Factories\HasFactory;' => "use Lunarstorm\LaravelDDD\Factories\HasDomainFactory as HasFactory;",
+ ];
- if ($this->option('factory')) {
- $this->createFactory();
+ if ($baseModel = $this->getBaseModel()) {
+ $baseModelClass = class_basename($baseModel);
+
+ $replacements = array_merge($replacements, [
+ 'extends Model' => "extends {$baseModelClass}",
+ 'use Illuminate\Database\Eloquent\Model;' => "use {$baseModel};",
+ ]);
}
+
+ $stub = str_replace(
+ array_keys($replacements),
+ array_values($replacements),
+ $stub
+ );
+
+ $stub = $this->sortImports($stub);
+
+ return $stub;
}
protected function createBaseModelIfNeeded()
{
- if (! $this->shouldCreateModel()) {
+ if (! $this->shouldCreateBaseModel()) {
return;
}
@@ -74,10 +79,19 @@ protected function createBaseModelIfNeeded()
]);
}
- protected function shouldCreateModel(): bool
+ protected function getBaseModel(): ?string
+ {
+ return config('ddd.base_model', null);
+ }
+
+ protected function shouldCreateBaseModel(): bool
{
$baseModel = config('ddd.base_model');
+ if (is_null($baseModel)) {
+ return false;
+ }
+
// If the class exists, we don't need to create it.
if (class_exists($baseModel)) {
return false;
@@ -96,13 +110,4 @@ protected function shouldCreateModel(): bool
return true;
}
-
- protected function createFactory()
- {
- $this->call(DomainFactoryMakeCommand::class, [
- 'name' => $this->getNameInput().'Factory',
- '--domain' => $this->domain->dotName,
- '--model' => $this->qualifyClass($this->getNameInput()),
- ]);
- }
}
diff --git a/src/Commands/DomainModelMakeLegacyCommand.php b/src/Commands/DomainModelMakeLegacyCommand.php
new file mode 100644
index 0000000..37c8164
--- /dev/null
+++ b/src/Commands/DomainModelMakeLegacyCommand.php
@@ -0,0 +1,108 @@
+resolveStubPath('model.php.stub');
+ }
+
+ protected function preparePlaceholders(): array
+ {
+ $baseClass = config('ddd.base_model');
+ $baseClassName = class_basename($baseClass);
+
+ return [
+ 'extends' => filled($baseClass) ? " extends {$baseClassName}" : '',
+ 'baseClassImport' => filled($baseClass) ? "use {$baseClass};" : '',
+ ];
+ }
+
+ public function handle()
+ {
+ $this->createBaseModelIfNeeded();
+
+ parent::handle();
+
+ if ($this->option('factory')) {
+ $this->createFactory();
+ }
+ }
+
+ protected function createBaseModelIfNeeded()
+ {
+ if (! $this->shouldCreateBaseModel()) {
+ return;
+ }
+
+ $baseModel = config('ddd.base_model');
+
+ $this->warn("Base model {$baseModel} doesn't exist, generating...");
+
+ $domain = DomainResolver::guessDomainFromClass($baseModel);
+
+ $name = Str::after($baseModel, $domain);
+
+ $this->call(DomainBaseModelMakeCommand::class, [
+ '--domain' => $domain,
+ 'name' => $name,
+ ]);
+ }
+
+ protected function shouldCreateBaseModel(): bool
+ {
+ $baseModel = config('ddd.base_model');
+
+ // If the class exists, we don't need to create it.
+ if (class_exists($baseModel)) {
+ return false;
+ }
+
+ // If the class is outside of the domain layer, we won't attempt to create it.
+ if (! DomainResolver::isDomainClass($baseModel)) {
+ return false;
+ }
+
+ // At this point the class is probably a domain object, but we should
+ // check if the expected path exists.
+ if (file_exists(app()->basePath(DomainResolver::guessPathFromClass($baseModel)))) {
+ return false;
+ }
+
+ return true;
+ }
+
+ protected function createFactory()
+ {
+ $this->call(DomainFactoryMakeCommand::class, [
+ 'name' => $this->getNameInput().'Factory',
+ '--domain' => $this->domain->dotName,
+ '--model' => $this->qualifyClass($this->getNameInput()),
+ ]);
+ }
+}
diff --git a/src/Commands/DomainRequestMakeCommand.php b/src/Commands/DomainRequestMakeCommand.php
new file mode 100644
index 0000000..a6a1ddf
--- /dev/null
+++ b/src/Commands/DomainRequestMakeCommand.php
@@ -0,0 +1,27 @@
+guessObjectType();
+
+ return Str::finish(DomainResolver::resolveRootNamespace($type), '\\');
+ }
+}
diff --git a/src/Commands/DomainSeederMakeCommand.php b/src/Commands/DomainSeederMakeCommand.php
new file mode 100644
index 0000000..6ee413d
--- /dev/null
+++ b/src/Commands/DomainSeederMakeCommand.php
@@ -0,0 +1,13 @@
+argument('name'));
+
+ return $name;
+ }
+
+ protected function qualifyModel(string $model) {}
+
+ protected function getDefaultNamespace($rootNamespace) {}
+
+ protected function getPath($name) {}
+}
diff --git a/src/Commands/Migration/DomainMigrateMakeCommand.php b/src/Commands/Migration/DomainMigrateMakeCommand.php
new file mode 100644
index 0000000..e0dcb73
--- /dev/null
+++ b/src/Commands/Migration/DomainMigrateMakeCommand.php
@@ -0,0 +1,27 @@
+domain) {
+ return $this->laravel->basePath($this->domain->migrationPath);
+ }
+
+ return $this->laravel->databasePath().DIRECTORY_SEPARATOR.'migrations';
+ }
+}
diff --git a/src/Commands/CacheClearCommand.php b/src/Commands/OptimizeClearCommand.php
similarity index 66%
rename from src/Commands/CacheClearCommand.php
rename to src/Commands/OptimizeClearCommand.php
index 7e4c465..3ad99c8 100644
--- a/src/Commands/CacheClearCommand.php
+++ b/src/Commands/OptimizeClearCommand.php
@@ -5,12 +5,21 @@
use Illuminate\Console\Command;
use Lunarstorm\LaravelDDD\Support\DomainCache;
-class CacheClearCommand extends Command
+class OptimizeClearCommand extends Command
{
protected $name = 'ddd:clear';
protected $description = 'Clear cached domain autoloaded objects.';
+ protected function configure()
+ {
+ $this->setAliases([
+ 'ddd:optimize:clear',
+ ]);
+
+ parent::configure();
+ }
+
public function handle()
{
DomainCache::clear();
diff --git a/src/Commands/OptimizeCommand.php b/src/Commands/OptimizeCommand.php
new file mode 100644
index 0000000..e73462e
--- /dev/null
+++ b/src/Commands/OptimizeCommand.php
@@ -0,0 +1,34 @@
+setAliases([
+ 'ddd:cache',
+ ]);
+
+ parent::configure();
+ }
+
+ public function handle()
+ {
+ $this->components->info('Caching DDD providers, commands, migration paths.');
+
+ $this->components->task('domain providers', fn () => DomainAutoloader::cacheProviders());
+ $this->components->task('domain commands', fn () => DomainAutoloader::cacheCommands());
+ $this->components->task('domain migration paths', fn () => DomainMigration::cachePaths());
+
+ $this->newLine();
+ }
+}
diff --git a/src/DomainManager.php b/src/DomainManager.php
index 2de6b0f..13d465a 100755
--- a/src/DomainManager.php
+++ b/src/DomainManager.php
@@ -2,6 +2,10 @@
namespace Lunarstorm\LaravelDDD;
+use Illuminate\Console\Command;
+use Lunarstorm\LaravelDDD\Support\Domain;
+use Lunarstorm\LaravelDDD\ValueObjects\DomainCommandContext;
+
class DomainManager
{
/**
@@ -11,9 +15,28 @@ class DomainManager
*/
protected $autoloadFilter;
+ /**
+ * The application layer filter callback.
+ *
+ * @var callable|null
+ */
+ protected $applicationLayerFilter;
+
+ /**
+ * The application layer object resolver callback.
+ *
+ * @var callable|null
+ */
+ protected $namespaceResolver;
+
+ protected ?DomainCommandContext $commandContext;
+
public function __construct()
{
$this->autoloadFilter = null;
+ $this->applicationLayerFilter = null;
+ $this->namespaceResolver = null;
+ $this->commandContext = null;
}
public function filterAutoloadPathsUsing(callable $filter): void
@@ -25,4 +48,34 @@ public function getAutoloadFilter(): ?callable
{
return $this->autoloadFilter;
}
+
+ public function filterApplicationLayerUsing(callable $filter): void
+ {
+ $this->applicationLayerFilter = $filter;
+ }
+
+ public function getApplicationLayerFilter(): ?callable
+ {
+ return $this->applicationLayerFilter;
+ }
+
+ public function resolveNamespaceUsing(callable $resolver): void
+ {
+ $this->namespaceResolver = $resolver;
+ }
+
+ public function getNamespaceResolver(): ?callable
+ {
+ return $this->namespaceResolver;
+ }
+
+ public function captureCommandContext(Command $command, ?Domain $domain, ?string $type): void
+ {
+ $this->commandContext = DomainCommandContext::fromCommand($command, $domain, $type);
+ }
+
+ public function getCommandContext(): ?DomainCommandContext
+ {
+ return $this->commandContext;
+ }
}
diff --git a/src/Facades/DDD.php b/src/Facades/DDD.php
index 5164cda..a5aba45 100644
--- a/src/Facades/DDD.php
+++ b/src/Facades/DDD.php
@@ -8,6 +8,7 @@
* @see \Lunarstorm\LaravelDDD\DomainManager
*
* @method static void filterAutoloadPathsUsing(callable $filter)
+ * @method static void resolveNamespaceUsing(callable $resolver)
*/
class DDD extends Facade
{
diff --git a/src/LaravelDDDServiceProvider.php b/src/LaravelDDDServiceProvider.php
index 6555823..106ec30 100644
--- a/src/LaravelDDDServiceProvider.php
+++ b/src/LaravelDDDServiceProvider.php
@@ -3,6 +3,7 @@
namespace Lunarstorm\LaravelDDD;
use Lunarstorm\LaravelDDD\Support\DomainAutoloader;
+use Lunarstorm\LaravelDDD\Support\DomainMigration;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
@@ -27,8 +28,8 @@ public function configurePackage(Package $package): void
->hasCommands([
Commands\InstallCommand::class,
Commands\UpgradeCommand::class,
- Commands\CacheCommand::class,
- Commands\CacheClearCommand::class,
+ Commands\OptimizeCommand::class,
+ Commands\OptimizeClearCommand::class,
Commands\DomainListCommand::class,
Commands\DomainModelMakeCommand::class,
Commands\DomainFactoryMakeCommand::class,
@@ -41,18 +42,23 @@ public function configurePackage(Package $package): void
Commands\DomainCastMakeCommand::class,
Commands\DomainChannelMakeCommand::class,
Commands\DomainConsoleMakeCommand::class,
+ Commands\DomainControllerMakeCommand::class,
Commands\DomainEventMakeCommand::class,
Commands\DomainExceptionMakeCommand::class,
Commands\DomainJobMakeCommand::class,
Commands\DomainListenerMakeCommand::class,
Commands\DomainMailMakeCommand::class,
+ Commands\DomainMiddlewareMakeCommand::class,
Commands\DomainNotificationMakeCommand::class,
Commands\DomainObserverMakeCommand::class,
Commands\DomainPolicyMakeCommand::class,
Commands\DomainProviderMakeCommand::class,
Commands\DomainResourceMakeCommand::class,
+ Commands\DomainRequestMakeCommand::class,
Commands\DomainRuleMakeCommand::class,
Commands\DomainScopeMakeCommand::class,
+ Commands\DomainSeederMakeCommand::class,
+ Commands\Migration\DomainMigrateMakeCommand::class,
]);
if (app()->version() >= 11) {
@@ -63,15 +69,40 @@ public function configurePackage(Package $package): void
}
}
+ protected function registerMigrations()
+ {
+ $this->app->singleton(Commands\Migration\DomainMigrateMakeCommand::class, function ($app) {
+ // Once we have the migration creator registered, we will create the command
+ // and inject the creator. The creator is responsible for the actual file
+ // creation of the migrations, and may be extended by these developers.
+ $creator = $app['migration.creator'];
+ $composer = $app['composer'];
+
+ return new Commands\Migration\DomainMigrateMakeCommand($creator, $composer);
+ });
+
+ $this->loadMigrationsFrom(DomainMigration::paths());
+ }
+
public function packageBooted()
{
$this->publishes([
$this->package->basePath('/../stubs') => resource_path("stubs/{$this->package->shortName()}"),
], "{$this->package->shortName()}-stubs");
+
+ if ($this->app->runningInConsole() && method_exists($this, 'optimizes')) {
+ $this->optimizes(
+ optimize: 'ddd:optimize',
+ clear: 'ddd:clear',
+ key: 'ddd cache',
+ );
+ }
}
public function packageRegistered()
{
(new DomainAutoloader)->autoload();
+
+ $this->registerMigrations();
}
}
diff --git a/src/Support/Domain.php b/src/Support/Domain.php
index 062aedc..225fe61 100644
--- a/src/Support/Domain.php
+++ b/src/Support/Domain.php
@@ -11,6 +11,8 @@ class Domain
public readonly string $path;
+ public readonly string $migrationPath;
+
public readonly string $domain;
public readonly ?string $subdomain;
@@ -57,6 +59,8 @@ public function __construct(string $domain, ?string $subdomain = null)
$this->namespace = DomainNamespaces::from($this->domain, $this->subdomain);
$this->path = Path::join(DomainResolver::domainPath(), $this->domainWithSubdomain);
+
+ $this->migrationPath = Path::join($this->path, config('ddd.namespaces.migration', 'Database/Migrations'));
}
protected function getDomainBasePath()
@@ -79,14 +83,29 @@ public function path(?string $path = null): string
return Path::join($this->path, $path);
}
+ public function pathInApplicationLayer(?string $path = null): string
+ {
+ if (is_null($path)) {
+ return $this->path;
+ }
+
+ $path = str($path)
+ ->replace(DomainResolver::applicationLayerRootNamespace(), '')
+ ->replace(['\\', '/'], DIRECTORY_SEPARATOR)
+ ->append('.php')
+ ->toString();
+
+ return Path::join(DomainResolver::applicationLayerPath(), $path);
+ }
+
public function relativePath(string $path = ''): string
{
return collect([$this->domain, $path])->filter()->implode(DIRECTORY_SEPARATOR);
}
- public function namespaceFor(string $type): string
+ public function namespaceFor(string $type, ?string $name = null): string
{
- return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type);
+ return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type, $name);
}
public function guessNamespaceFromName(string $name): string
@@ -102,20 +121,37 @@ public function guessNamespaceFromName(string $name): string
public function object(string $type, string $name, bool $absolute = false): DomainObject
{
- $namespace = match (true) {
+ $resolvedNamespace = null;
+
+ if (DomainResolver::isApplicationLayer($type)) {
+ $resolver = app('ddd')->getNamespaceResolver();
+
+ $resolvedNamespace = is_callable($resolver)
+ ? $resolver($this->domainWithSubdomain, $type, app('ddd')->getCommandContext())
+ : null;
+ }
+
+ $namespace = $resolvedNamespace ?? match (true) {
$absolute => $this->namespace->root,
str($name)->startsWith('\\') => $this->guessNamespaceFromName($name),
default => $this->namespaceFor($type),
};
- $baseName = str($name)->replace($namespace, '')->trim('\\')->toString();
+ $baseName = str($name)->replace($namespace, '')
+ ->replace(['\\', '/'], '\\')
+ ->trim('\\')
+ ->toString();
+
+ $fullyQualifiedName = $namespace.'\\'.$baseName;
return new DomainObject(
name: $baseName,
domain: $this->domain,
namespace: $namespace,
- fullyQualifiedName: $namespace.'\\'.$baseName,
- path: $this->path($namespace.'\\'.$baseName),
+ fullyQualifiedName: $fullyQualifiedName,
+ path: DomainResolver::isApplicationLayer($type)
+ ? $this->pathInApplicationLayer($fullyQualifiedName)
+ : $this->path($fullyQualifiedName),
type: $type
);
}
diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php
index beae39e..9e812d9 100644
--- a/src/Support/DomainAutoloader.php
+++ b/src/Support/DomainAutoloader.php
@@ -154,7 +154,9 @@ protected static function discoverProviders(): array
}
$paths = static::normalizePaths(
- $configValue === true ? app()->basePath(DomainResolver::domainPath()) : $configValue
+ $configValue === true
+ ? app()->basePath(DomainResolver::domainPath())
+ : $configValue
);
if (empty($paths)) {
diff --git a/src/Support/DomainMigration.php b/src/Support/DomainMigration.php
new file mode 100644
index 0000000..9f3a939
--- /dev/null
+++ b/src/Support/DomainMigration.php
@@ -0,0 +1,81 @@
+filter(fn ($path) => is_dir($path))
+ ->toArray();
+ }
+
+ public static function discoverPaths(): array
+ {
+ $configValue = config('ddd.autoload.migrations', true);
+
+ if ($configValue === false) {
+ return [];
+ }
+
+ $paths = static::normalizePaths([
+ app()->basePath(DomainResolver::domainPath()),
+ ]);
+
+ if (empty($paths)) {
+ return [];
+ }
+
+ $finder = static::finder($paths);
+
+ return Lody::filesFromFinder($finder)
+ ->map(fn ($file) => Path::normalize($file->getPath()))
+ ->unique()
+ ->values()
+ ->toArray();
+ }
+
+ protected static function finder(array $paths)
+ {
+ $filter = function (SplFileInfo $file) {
+ $configuredMigrationFolder = static::domainMigrationFolder();
+
+ $relativePath = Path::normalize($file->getRelativePath());
+
+ return Str::endsWith($relativePath, $configuredMigrationFolder);
+ };
+
+ return Finder::create()
+ ->files()
+ ->in($paths)
+ ->filter($filter);
+ }
+}
diff --git a/src/Support/DomainResolver.php b/src/Support/DomainResolver.php
index 9d52db8..0d9110c 100644
--- a/src/Support/DomainResolver.php
+++ b/src/Support/DomainResolver.php
@@ -35,6 +35,22 @@ public static function domainRootNamespace(): ?string
return config('ddd.domain_namespace');
}
+ /**
+ * Get the current configured application layer path.
+ */
+ public static function applicationLayerPath(): ?string
+ {
+ return config('ddd.application.path');
+ }
+
+ /**
+ * Get the current configured root application layer namespace.
+ */
+ public static function applicationLayerRootNamespace(): ?string
+ {
+ return config('ddd.application.namespace');
+ }
+
/**
* Resolve the relative domain object namespace.
*
@@ -45,19 +61,66 @@ public static function getRelativeObjectNamespace(string $type): string
return config("ddd.namespaces.{$type}", str($type)->plural()->studly()->toString());
}
- public static function getDomainObjectNamespace(string $domain, string $type, ?string $object = null): string
+ /**
+ * Determine whether a given object type is part of the application layer.
+ */
+ public static function isApplicationLayer(string $type): bool
+ {
+ $filter = app('ddd')->getApplicationLayerFilter() ?? function (string $type) {
+ $applicationObjects = config('ddd.application.objects', ['controller', 'request']);
+
+ return in_array($type, $applicationObjects);
+ };
+
+ return $filter($type);
+ }
+
+ /**
+ * Resolve the root namespace for a given domain object type.
+ *
+ * @param string $type The domain object type.
+ */
+ public static function resolveRootNamespace(string $type): ?string
{
- $namespace = collect([
- static::domainRootNamespace(),
- $domain,
- static::getRelativeObjectNamespace($type),
- ])->filter()->implode('\\');
-
- if ($object) {
- $namespace .= "\\{$object}";
+ return static::isApplicationLayer($type)
+ ? static::applicationLayerRootNamespace()
+ : static::domainRootNamespace();
+ }
+
+ /**
+ * Get the fully qualified namespace for a domain object.
+ *
+ * @param string $domain The domain name.
+ * @param string $type The domain object type.
+ * @param string|null $name The domain object name.
+ */
+ public static function getDomainObjectNamespace(string $domain, string $type, ?string $name = null): string
+ {
+ $customResolver = app('ddd')->getNamespaceResolver();
+
+ $resolved = is_callable($customResolver)
+ ? $customResolver($domain, $type, app('ddd')->getCommandContext())
+ : null;
+
+ if (! is_null($resolved)) {
+ return $resolved;
}
- return $namespace;
+ $resolver = function (string $domain, string $type, ?string $name) {
+ $namespace = collect([
+ static::resolveRootNamespace($type),
+ $domain,
+ static::getRelativeObjectNamespace($type),
+ ])->filter()->implode('\\');
+
+ if ($name) {
+ $namespace .= "\\{$name}";
+ }
+
+ return $namespace;
+ };
+
+ return $resolver($domain, $type, $name);
}
/**
@@ -95,6 +158,22 @@ public static function guessPathFromClass(string $class): ?string
return Path::join(...[static::domainPath(), "{$classWithoutDomainRoot}.php"]);
}
+ /**
+ * Attempt to resolve the folder of a given domain class.
+ */
+ public static function guessFolderFromClass(string $class): ?string
+ {
+ $path = static::guessPathFromClass($class);
+
+ if (! $path) {
+ return null;
+ }
+
+ $filenamePortion = basename($path);
+
+ return Str::beforeLast($path, $filenamePortion);
+ }
+
/**
* Determine whether a class is an object within the domain layer.
*
diff --git a/src/Support/Path.php b/src/Support/Path.php
index 931230c..7fbdb86 100644
--- a/src/Support/Path.php
+++ b/src/Support/Path.php
@@ -26,4 +26,9 @@ public static function filePathToNamespace(string $path, string $namespacePath,
$path
);
}
+
+ public static function normalizeNamespace(string $namespace): string
+ {
+ return str_replace(['\\', '/'], '\\', $namespace);
+ }
}
diff --git a/src/ValueObjects/DomainCommandContext.php b/src/ValueObjects/DomainCommandContext.php
new file mode 100644
index 0000000..692ceed
--- /dev/null
+++ b/src/ValueObjects/DomainCommandContext.php
@@ -0,0 +1,51 @@
+getName(),
+ domain: $domain?->domainWithSubdomain,
+ type: $type,
+ resource: $command->argument('name'),
+ arguments: $command->arguments(),
+ options: $command->options(),
+ );
+ }
+
+ public function hasOption(string $key): bool
+ {
+ return array_key_exists($key, $this->options);
+ }
+
+ public function option(string $key): mixed
+ {
+ return data_get($this->options, $key);
+ }
+
+ public function hasArgument(string $key): bool
+ {
+ return array_key_exists($key, $this->arguments);
+ }
+
+ public function argument(string $key): mixed
+ {
+ return data_get($this->arguments, $key);
+ }
+}
diff --git a/tests/.skeleton/src/Domain/Invoicing/Database/Migrations/2024_10_14_215911_do_nothing.php b/tests/.skeleton/src/Domain/Invoicing/Database/Migrations/2024_10_14_215911_do_nothing.php
new file mode 100644
index 0000000..88fa2f3
--- /dev/null
+++ b/tests/.skeleton/src/Domain/Invoicing/Database/Migrations/2024_10_14_215911_do_nothing.php
@@ -0,0 +1,24 @@
+setupTestApplication();
+});
+
+it('can register a custom namespace resolver', function () {
+ Config::set('ddd.application', [
+ 'path' => 'src/App',
+ 'namespace' => 'App',
+ ]);
+
+ DDD::resolveNamespaceUsing(function (string $domain, string $type, ?DomainCommandContext $context): ?string {
+ if ($type == 'controller' && $context->option('api')) {
+ return "App\\Api\\Controllers\\{$domain}";
+ }
+
+ return null;
+ });
+
+ Artisan::call('ddd:controller', [
+ 'name' => 'PaymentApiController',
+ '--domain' => 'Invoicing',
+ '--api' => true,
+ ]);
+
+ $output = Artisan::output();
+
+ expect($output)
+ ->toContainFilepath('src/App/Api/Controllers/Invoicing/PaymentApiController.php');
+
+ $expectedPath = base_path('src/App/Api/Controllers/Invoicing/PaymentApiController.php');
+
+ expect(file_get_contents($expectedPath))
+ ->toContain("namespace App\Api\Controllers\Invoicing;");
+});
diff --git a/tests/Command/CacheTest.php b/tests/Command/CacheTest.php
index 81a3587..f7aec3f 100644
--- a/tests/Command/CacheTest.php
+++ b/tests/Command/CacheTest.php
@@ -2,21 +2,31 @@
use Illuminate\Support\Facades\Artisan;
use Lunarstorm\LaravelDDD\Support\DomainCache;
+use Lunarstorm\LaravelDDD\Tests\Fixtures\Enums\Feature;
beforeEach(function () {
$this->setupTestApplication();
+
+ config(['cache.default' => 'file']);
+
DomainCache::clear();
});
-it('can cache discovered domain providers and commands', function () {
- expect(DomainCache::get('domain-providers'))->toBeNull();
+afterEach(function () {
+ $this->artisan('optimize:clear')->execute();
+});
+it('can cache discovered domain providers, commands, migrations', function () {
+ expect(DomainCache::get('domain-providers'))->toBeNull();
expect(DomainCache::get('domain-commands'))->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->toBeNull();
$this
->artisan('ddd:cache')
- ->expectsOutputToContain('Domain providers cached successfully.')
- ->expectsOutputToContain('Domain commands cached successfully.')
+ ->expectsOutputToContain('Caching DDD providers, commands, migration paths.')
+ ->expectsOutputToContain('domain providers')
+ ->expectsOutputToContain('domain commands')
+ ->expectsOutputToContain('domain migration paths')
->execute();
expect(DomainCache::get('domain-providers'))
@@ -24,6 +34,10 @@
expect(DomainCache::get('domain-commands'))
->toContain('Domain\Invoicing\Commands\InvoiceDeliver');
+
+ $paths = collect(DomainCache::get('domain-migration-paths'))->join("\n");
+
+ expect($paths)->toContainFilepath('src/Domain/Invoicing/Database/Migrations');
});
it('can clear the cache', function () {
@@ -31,6 +45,7 @@
expect(DomainCache::get('domain-providers'))->not->toBeNull();
expect(DomainCache::get('domain-commands'))->not->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->not->toBeNull();
$this
->artisan('ddd:clear')
@@ -39,26 +54,63 @@
expect(DomainCache::get('domain-providers'))->toBeNull();
expect(DomainCache::get('domain-commands'))->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->toBeNull();
});
it('will not be cleared by laravel cache clearing', function () {
- config(['cache.default' => 'file']);
-
expect(DomainCache::get('domain-providers'))->toBeNull();
expect(DomainCache::get('domain-commands'))->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->toBeNull();
$this->artisan('ddd:cache')->execute();
expect(DomainCache::get('domain-providers'))->not->toBeNull();
expect(DomainCache::get('domain-commands'))->not->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->not->toBeNull();
$this->artisan('cache:clear')->execute();
expect(DomainCache::get('domain-providers'))->not->toBeNull();
expect(DomainCache::get('domain-commands'))->not->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->not->toBeNull();
- $this->artisan('optimize:clear')->execute();
+ if (Feature::LaravelPackageOptimizeCommands->missing()) {
+ $this->artisan('optimize:clear')->execute();
- expect(DomainCache::get('domain-providers'))->not->toBeNull();
- expect(DomainCache::get('domain-commands'))->not->toBeNull();
+ expect(DomainCache::get('domain-providers'))->not->toBeNull();
+ expect(DomainCache::get('domain-commands'))->not->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->not->toBeNull();
+ }
});
+
+describe('laravel optimize', function () {
+ test('optimize will include ddd:cache', function () {
+ config(['cache.default' => 'file']);
+
+ expect(DomainCache::get('domain-providers'))->toBeNull();
+ expect(DomainCache::get('domain-commands'))->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->toBeNull();
+
+ $this->artisan('optimize')->execute();
+
+ expect(DomainCache::get('domain-providers'))->not->toBeNull();
+ expect(DomainCache::get('domain-commands'))->not->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->not->toBeNull();
+ });
+
+ test('optimize:clear will clear ddd cache', function () {
+ config(['cache.default' => 'file']);
+
+ $this->artisan('ddd:cache')->execute();
+
+ expect(DomainCache::get('domain-providers'))->not->toBeNull();
+ expect(DomainCache::get('domain-commands'))->not->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->not->toBeNull();
+
+ $this->artisan('optimize:clear')->execute();
+
+ expect(DomainCache::get('domain-providers'))->toBeNull();
+ expect(DomainCache::get('domain-commands'))->toBeNull();
+ expect(DomainCache::get('domain-migration-paths'))->toBeNull();
+ });
+})->skipOnLaravelVersionsBelow(Feature::LaravelPackageOptimizeCommands->value);
diff --git a/tests/Command/ListTest.php b/tests/Command/ListTest.php
index 36afcee..6a1443b 100644
--- a/tests/Command/ListTest.php
+++ b/tests/Command/ListTest.php
@@ -13,6 +13,11 @@
'--domain' => 'Customer',
]);
+ $this->artisan('ddd:value', [
+ 'name' => 'Subtotal',
+ '--domain' => 'Shared',
+ ]);
+
$this->expectedDomains = [
'Customer',
'Invoicing',
diff --git a/tests/Fixtures/Enums/Feature.php b/tests/Fixtures/Enums/Feature.php
index 21c2aa1..9b82fbb 100644
--- a/tests/Fixtures/Enums/Feature.php
+++ b/tests/Fixtures/Enums/Feature.php
@@ -7,6 +7,7 @@ enum Feature: string
case PromptForMissingInput = '9.49.0';
case IncludeFilepathInGeneratorCommandOutput = '9.32.0';
case LaravelPromptsPackage = '10.17';
+ case LaravelPackageOptimizeCommands = '11.27.1';
public function exists(): bool
{
diff --git a/tests/Generator/MakeActionTest.php b/tests/Generator/ActionMakeTest.php
similarity index 100%
rename from tests/Generator/MakeActionTest.php
rename to tests/Generator/ActionMakeTest.php
diff --git a/tests/Generator/MakeBaseModelTest.php b/tests/Generator/BaseModelMakeTest.php
similarity index 100%
rename from tests/Generator/MakeBaseModelTest.php
rename to tests/Generator/BaseModelMakeTest.php
diff --git a/tests/Generator/MakeBaseViewModelTest.php b/tests/Generator/BaseViewModelMakeTest.php
similarity index 100%
rename from tests/Generator/MakeBaseViewModelTest.php
rename to tests/Generator/BaseViewModelMakeTest.php
diff --git a/tests/Generator/ControllerMakeTest.php b/tests/Generator/ControllerMakeTest.php
new file mode 100644
index 0000000..5aae081
--- /dev/null
+++ b/tests/Generator/ControllerMakeTest.php
@@ -0,0 +1,177 @@
+ 'app/Modules',
+ 'namespace' => 'App\Modules',
+ 'objects' => ['controller', 'request'],
+ ]);
+
+ $this->setupTestApplication();
+});
+
+it('can generate domain controller', function ($domainName, $controllerName, $relativePath, $expectedNamespace) {
+ $expectedPath = base_path($relativePath);
+
+ if (file_exists($expectedPath)) {
+ unlink($expectedPath);
+ }
+
+ expect(file_exists($expectedPath))->toBeFalse();
+
+ Artisan::call("ddd:controller {$domainName}:{$controllerName}");
+
+ expect($output = Artisan::output())->when(
+ Feature::IncludeFilepathInGeneratorCommandOutput->exists(),
+ fn ($output) => $output->toContainFilepath($relativePath),
+ );
+
+ expect(file_exists($expectedPath))->toBeTrue();
+
+ expect(file_get_contents($expectedPath))
+ ->toContain("namespace {$expectedNamespace};");
+})->with([
+ 'Invoicing:InvoiceController' => [
+ 'Invoicing',
+ 'InvoiceController',
+ 'app/Modules/Invoicing/Controllers/InvoiceController.php',
+ 'App\Modules\Invoicing\Controllers',
+ ],
+
+ 'Reporting.Internal:ReportSubmissionController' => [
+ 'Reporting.Internal',
+ 'ReportSubmissionController',
+ 'app/Modules/Reporting/Internal/Controllers/ReportSubmissionController.php',
+ 'App\Modules\Reporting\Internal\Controllers',
+ ],
+]);
+
+it('can generate domain resource controller from model', function ($domainName, $controllerName, $relativePath, $modelName, $modelClass) {
+ $expectedPath = base_path($relativePath);
+
+ if (file_exists($expectedPath)) {
+ unlink($expectedPath);
+ }
+
+ expect(file_exists($expectedPath))->toBeFalse();
+
+ Artisan::call('ddd:controller', [
+ 'name' => $controllerName,
+ '--domain' => $domainName,
+ '--model' => $modelName,
+ ]);
+
+ expect(file_exists($expectedPath))->toBeTrue();
+
+ $modelVariable = lcfirst(class_basename($modelClass));
+
+ expect(file_get_contents($expectedPath))
+ ->toContain("use {$modelClass};")
+ ->toContain("{$modelName} \${$modelVariable})");
+})->with([
+ 'Invoicing:InvoiceController --model=Invoice' => [
+ 'Invoicing',
+ 'InvoiceController',
+ 'app/Modules/Invoicing/Controllers/InvoiceController.php',
+ 'Invoice',
+ 'Domain\Invoicing\Models\Invoice',
+ ],
+
+ 'Invoicing:Payment/InvoicePaymentController --model=InvoicePayment' => [
+ 'Invoicing',
+ 'Payment/InvoicePaymentController',
+ 'app/Modules/Invoicing/Controllers/Payment/InvoicePaymentController.php',
+ 'InvoicePayment',
+ 'Domain\Invoicing\Models\InvoicePayment',
+ ],
+
+ 'Reporting.Internal:Archived/ReportArchiveController --model=ReportArchive' => [
+ 'Reporting.Internal',
+ 'Archived/ReportArchiveController',
+ 'app/Modules/Reporting/Internal/Controllers/Archived/ReportArchiveController.php',
+ 'ReportArchive',
+ 'Domain\Reporting\Internal\Models\ReportArchive',
+ ],
+]);
+
+it('can generate domain controller with requests', function ($domainName, $controllerName, $controllerPath, $modelName, $modelClass, $generatedPaths) {
+ $generatedPaths = [
+ $controllerPath,
+ ...$generatedPaths,
+ ];
+
+ foreach ($generatedPaths as $path) {
+ $path = base_path($path);
+
+ if (file_exists($path)) {
+ unlink($path);
+ }
+ }
+
+ Artisan::call('ddd:controller', [
+ 'name' => $controllerName,
+ '--domain' => $domainName,
+ '--model' => $modelName,
+ '--requests' => true,
+ ]);
+
+ $output = Artisan::output();
+
+ foreach ($generatedPaths as $path) {
+ if (Feature::IncludeFilepathInGeneratorCommandOutput->exists()) {
+ expect($output)->toContainFilepath($path);
+ }
+
+ expect(file_exists(base_path($path)))->toBeTrue("Expecting {$path} to exist");
+ }
+
+ $modelVariable = lcfirst(class_basename($modelClass));
+
+ expect(file_get_contents(base_path($controllerPath)))
+ ->toContain("use {$modelClass};")
+ ->toContain("store(Store{$modelName}Request \$request)")
+ ->toContain("update(Update{$modelName}Request \$request, {$modelName} \${$modelVariable})");
+})->with([
+ 'Invoicing:InvoiceController --model=Invoice' => [
+ 'Invoicing',
+ 'InvoiceController',
+ 'app/Modules/Invoicing/Controllers/InvoiceController.php',
+ 'Invoice',
+ 'Domain\Invoicing\Models\Invoice',
+ [
+ 'app/Modules/Invoicing/Requests/StoreInvoiceRequest.php',
+ 'app/Modules/Invoicing/Requests/UpdateInvoiceRequest.php',
+ ],
+ ],
+
+ 'Invoicing:Payment/InvoicePaymentController --model=InvoicePayment' => [
+ 'Invoicing',
+ 'Payment/InvoicePaymentController',
+ 'app/Modules/Invoicing/Controllers/Payment/InvoicePaymentController.php',
+ 'InvoicePayment',
+ 'Domain\Invoicing\Models\InvoicePayment',
+ [
+ 'app/Modules/Invoicing/Requests/Payment/StoreInvoicePaymentRequest.php',
+ 'app/Modules/Invoicing/Requests/Payment/UpdateInvoicePaymentRequest.php',
+ ],
+ ],
+
+ 'Reporting.Internal:Archived/ReportArchiveController --model=ReportArchive' => [
+ 'Reporting.Internal',
+ 'Archived/ReportArchiveController',
+ 'app/Modules/Reporting/Internal/Controllers/Archived/ReportArchiveController.php',
+ 'ReportArchive',
+ 'Domain\Reporting\Internal\Models\ReportArchive',
+ [
+ 'app/Modules/Reporting/Internal/Requests/Archived/StoreReportArchiveRequest.php',
+ 'app/Modules/Reporting/Internal/Requests/Archived/UpdateReportArchiveRequest.php',
+ ],
+ ],
+]);
diff --git a/tests/Generator/MakeDataTransferObjectTest.php b/tests/Generator/DtoMakeTestTest.php
similarity index 100%
rename from tests/Generator/MakeDataTransferObjectTest.php
rename to tests/Generator/DtoMakeTestTest.php
diff --git a/tests/Generator/ExtendedCommandsTest.php b/tests/Generator/ExtendedCommandsTest.php
index 4c0819f..52d8959 100644
--- a/tests/Generator/ExtendedCommandsTest.php
+++ b/tests/Generator/ExtendedCommandsTest.php
@@ -38,18 +38,22 @@
'cast' => ['cast', 'SomeCast'],
'channel' => ['channel', 'SomeChannel'],
'command' => ['command', 'SomeCommand'],
+ 'controller' => ['controller', 'SomeController'],
'event' => ['event', 'SomeEvent'],
'exception' => ['exception', 'SomeException'],
'job' => ['job', 'SomeJob'],
'listener' => ['listener', 'SomeListener'],
'mail' => ['mail', 'SomeMail'],
+ 'middleware' => ['middleware', 'SomeMiddleware'],
'notification' => ['notification', 'SomeNotification'],
'observer' => ['observer', 'SomeObserver'],
'policy' => ['policy', 'SomePolicy'],
'provider' => ['provider', 'SomeProvider'],
'resource' => ['resource', 'SomeResource'],
+ 'request' => ['request', 'SomeRequest'],
'rule' => ['rule', 'SomeRule'],
'scope' => ['scope', 'SomeScope'],
+ 'seeder' => ['seeder', 'SomeSeeder'],
'class' => ['class', 'SomeClass'],
'enum' => ['enum', 'SomeEnum'],
'interface' => ['interface', 'SomeInterface'],
diff --git a/tests/Generator/MakeFactoryTest.php b/tests/Generator/FactoryMakeTest.php
similarity index 100%
rename from tests/Generator/MakeFactoryTest.php
rename to tests/Generator/FactoryMakeTest.php
diff --git a/tests/Generator/MigrationMakeTest.php b/tests/Generator/MigrationMakeTest.php
new file mode 100644
index 0000000..c32c614
--- /dev/null
+++ b/tests/Generator/MigrationMakeTest.php
@@ -0,0 +1,84 @@
+ true,
+ ]);
+
+ DomainCache::clear();
+});
+
+it('can generate domain migrations', function ($domainPath, $domainRoot) {
+ Config::set('ddd.domain_path', $domainPath);
+ Config::set('ddd.domain_namespace', $domainRoot);
+
+ $domain = 'Invoicing';
+
+ $relativePath = implode('/', [
+ $domainPath,
+ $domain,
+ config('ddd.namespaces.migration'),
+ ]);
+
+ $migrationFolder = base_path(Path::normalize($relativePath));
+
+ $filesBefore = glob("{$migrationFolder}/*");
+
+ expect(count($filesBefore))->toBe(0);
+
+ Artisan::call("ddd:migration {$domain}:CreateInvoicesTable");
+
+ expect($output = Artisan::output())->when(
+ Feature::IncludeFilepathInGeneratorCommandOutput->exists(),
+ fn ($output) => $output
+ ->toContainFilepath($relativePath)
+ ->toContain('_create_invoices_table.php'),
+ );
+
+ $filesAfter = glob("{$migrationFolder}/*");
+
+ $createdMigrationFile = Arr::last($filesAfter);
+
+ expect($createdMigrationFile)->toEndWith('_create_invoices_table.php');
+
+ expect(file_get_contents($createdMigrationFile))
+ ->toContain('return new class extends Migration');
+})->with('domainPaths');
+
+it('discovers domain migration folders', function ($domainPath, $domainRoot) {
+ Config::set('ddd.domain_path', $domainPath);
+ Config::set('ddd.domain_namespace', $domainRoot);
+
+ $discoveredPaths = DomainMigration::discoverPaths();
+
+ expect($discoveredPaths)->toHaveCount(0);
+
+ Artisan::call('ddd:migration Invoicing:'.uniqid('migration'));
+ Artisan::call('ddd:migration Shared:'.uniqid('migration'));
+ Artisan::call('ddd:migration Reporting:'.uniqid('migration'));
+ Artisan::call('ddd:migration Reporting:'.uniqid('migration'));
+ Artisan::call('ddd:migration Reporting:'.uniqid('migration'));
+
+ $discoveredPaths = DomainMigration::discoverPaths();
+
+ expect($discoveredPaths)->toHaveCount(3);
+
+ $expectedFolderPatterns = [
+ Path::normalize('Invoicing/Database/Migrations'),
+ Path::normalize('Shared/Database/Migrations'),
+ Path::normalize('Reporting/Database/Migrations'),
+ ];
+
+ foreach ($discoveredPaths as $path) {
+ expect(str($path)->contains($expectedFolderPatterns))
+ ->toBeTrue('Expecting path to contain one of the expected folder patterns');
+ }
+})->with('domainPaths');
diff --git a/tests/Generator/MakeModelTest.php b/tests/Generator/Model/MakeTest.php
similarity index 83%
rename from tests/Generator/MakeModelTest.php
rename to tests/Generator/Model/MakeTest.php
index 8772f20..4b841a5 100644
--- a/tests/Generator/MakeModelTest.php
+++ b/tests/Generator/Model/MakeTest.php
@@ -42,7 +42,8 @@
config('ddd.namespaces.model'),
]);
- expect(file_get_contents($expectedModelPath))->toContain("namespace {$expectedNamespace};");
+ expect(file_get_contents($expectedModelPath))
+ ->toContain("namespace {$expectedNamespace};");
})->with('domainPaths');
it('can generate a domain model with factory', function ($domainPath, $domainRoot, $domainName, $subdomain) {
@@ -179,3 +180,34 @@
['Illuminate\Database\Eloquent\Model'],
['Lunarstorm\LaravelDDD\Models\DomainModel'],
]);
+
+it('extends custom base models when applicable', function ($baseModelClass, $baseModelName) {
+ Config::set('ddd.base_model', $baseModelClass);
+
+ $domain = 'Fruits';
+ $modelName = 'Lemon';
+
+ $expectedModelPath = base_path(implode('/', [
+ config('ddd.domain_path'),
+ $domain,
+ config('ddd.namespaces.model'),
+ "{$modelName}.php",
+ ]));
+
+ if (file_exists($expectedModelPath)) {
+ unlink($expectedModelPath);
+ }
+
+ Artisan::call("ddd:model {$domain}:{$modelName}");
+
+ expect(file_exists($expectedModelPath))->toBeTrue();
+
+ expect(file_get_contents($expectedModelPath))
+ ->toContain("use {$baseModelClass};")
+ ->toContain("extends {$baseModelName}");
+})->with([
+ ['Domain\Shared\Models\BaseModel', 'BaseModel'],
+ ['Lunarstorm\LaravelDDD\Models\DomainModel', 'DomainModel'],
+ ['Illuminate\Database\Eloquent\NonExistentModel', 'NonExistentModel'],
+ ['OtherVendor\OtherPackage\Models\NonExistentModel', 'NonExistentModel'],
+]);
diff --git a/tests/Generator/Model/MakeWithControllerTest.php b/tests/Generator/Model/MakeWithControllerTest.php
new file mode 100644
index 0000000..629933f
--- /dev/null
+++ b/tests/Generator/Model/MakeWithControllerTest.php
@@ -0,0 +1,90 @@
+setupTestApplication();
+});
+
+it('can generate domain model with controller', function ($domainName, $modelName, $controllerName, $generatedPaths) {
+ $domain = new Domain($domainName);
+
+ foreach ($generatedPaths as $path) {
+ $path = base_path($path);
+
+ if (file_exists($path)) {
+ unlink($path);
+ }
+ }
+
+ $command = [
+ 'ddd:model',
+ [
+ 'name' => $modelName,
+ '--domain' => $domain->dotName,
+ '--controller' => true,
+ ],
+ ];
+
+ Artisan::call(...$command);
+
+ $output = Artisan::output();
+
+ foreach ($generatedPaths as $path) {
+ if (Feature::IncludeFilepathInGeneratorCommandOutput->exists()) {
+ expect($output)->toContainFilepath($path);
+ }
+
+ expect(file_exists(base_path($path)))->toBeTrue("Expecting {$path} to exist");
+ }
+})->with([
+ 'Invoicing:Record' => [
+ 'Invoicing',
+ 'Record',
+ 'RecordController',
+ [
+ 'src/Domain/Invoicing/Models/Record.php',
+ 'app/Modules/Invoicing/Controllers/RecordController.php',
+ ],
+ ],
+
+ 'Invoicing:RecordEntry' => [
+ 'Invoicing',
+ 'RecordEntry',
+ 'RecordEntryController',
+ [
+ 'src/Domain/Invoicing/Models/RecordEntry.php',
+ 'app/Modules/Invoicing/Controllers/RecordEntryController.php',
+ ],
+ ],
+
+ 'Reporting.Internal:ReportSubmission' => [
+ 'Reporting.Internal',
+ 'ReportSubmission',
+ 'ReportSubmissionController',
+ [
+ 'src/Domain/Reporting/Internal/Models/ReportSubmission.php',
+ 'app/Modules/Reporting/Internal/Controllers/ReportSubmissionController.php',
+ ],
+ ],
+
+ // '--controller --api' => [
+ // ['--controller' => true, '--api' => true],
+ // 'RecordController',
+ // 'app/Http/Controllers/Invoicing/RecordController.php',
+ // ],
+
+ // '--controller --requests' => [
+ // ['--controller' => true, '--requests' => true],
+ // 'RecordController',
+ // 'app/Http/Controllers/Invoicing/RecordController.php',
+ // ],
+]);
diff --git a/tests/Generator/Model/MakeWithOptionsTest.php b/tests/Generator/Model/MakeWithOptionsTest.php
new file mode 100644
index 0000000..09cb201
--- /dev/null
+++ b/tests/Generator/Model/MakeWithOptionsTest.php
@@ -0,0 +1,109 @@
+model($modelName);
+
+// $domainFactory = $domain->factory($factoryName);
+
+// $expectedModelPath = base_path($domainModel->path);
+
+// if (file_exists($expectedModelPath)) {
+// unlink($expectedModelPath);
+// }
+
+// $expectedFactoryPath = base_path($domainFactory->path);
+
+// if (file_exists($expectedFactoryPath)) {
+// unlink($expectedFactoryPath);
+// }
+
+// Artisan::call('ddd:model', [
+// 'name' => $modelName,
+// '--domain' => $domain->dotName,
+// '--factory' => true,
+// ]);
+
+// $output = Artisan::output();
+
+// expect($output)->toContainFilepath($domainModel->path);
+
+// expect(file_exists($expectedModelPath))->toBeTrue("Expecting model file to be generated at {$expectedModelPath}");
+// expect(file_exists($expectedFactoryPath))->toBeTrue("Expecting factory file to be generated at {$expectedFactoryPath}");
+
+// expect(file_get_contents($expectedFactoryPath))
+// ->toContain("use {$domainModel->fullyQualifiedName};")
+// ->toContain("protected \$model = {$modelName}::class;");
+// });
+
+it('can generate domain model with options', function ($options, $objectType, $objectName, $expectedObjectPath) {
+ $domainName = 'Invoicing';
+ $modelName = 'Record';
+
+ $domain = new Domain($domainName);
+
+ $domainModel = $domain->model($modelName);
+
+ $expectedModelPath = base_path($domainModel->path);
+
+ if (file_exists($expectedModelPath)) {
+ unlink($expectedModelPath);
+ }
+
+ if (file_exists($expectedObjectPath)) {
+ unlink($expectedObjectPath);
+ }
+
+ $command = [
+ 'ddd:model', [
+ 'name' => $modelName,
+ '--domain' => $domain->dotName,
+ ...$options,
+ ],
+ ];
+
+ $this->artisan(...$command)
+ ->expectsOutputToContain(Path::normalize($domainModel->path))
+ ->assertExitCode(0);
+
+ $path = base_path($expectedObjectPath);
+
+ expect(file_exists($path))->toBeTrue("Expecting {$objectType} to be generated at {$path}");
+})->with([
+ '--factory' => [
+ ['--factory' => true],
+ 'factory',
+ 'RecordFactory',
+ 'src/Domain/Invoicing/Database/Factories/RecordFactory.php',
+ ],
+
+ '--seed' => [
+ ['--seed' => true],
+ 'seeder',
+ 'RecordSeeder',
+ 'src/Domain/Invoicing/Database/Seeders/RecordSeeder.php',
+ ],
+
+ '--policy' => [
+ ['--policy' => true],
+ 'policy',
+ 'RecordPolicy',
+ 'src/Domain/Invoicing/Policies/RecordPolicy.php',
+ ],
+]);
diff --git a/tests/Generator/PromptTest.php b/tests/Generator/PromptTest.php
index 3dea905..53bb086 100644
--- a/tests/Generator/PromptTest.php
+++ b/tests/Generator/PromptTest.php
@@ -23,6 +23,7 @@
it('[model] prompts for missing input', function () {
$this->artisan('ddd:model')
->expectsQuestion('What should the model be named?', 'Belt')
+ ->expectsQuestion('Would you like any of the following?', [])
->expectsQuestion('What is the domain?', 'Utility')
->assertExitCode(0);
});
diff --git a/tests/Generator/RequestMakeTest.php b/tests/Generator/RequestMakeTest.php
new file mode 100644
index 0000000..cc24949
--- /dev/null
+++ b/tests/Generator/RequestMakeTest.php
@@ -0,0 +1,54 @@
+ 'app/Modules',
+ 'namespace' => 'App\Modules',
+ 'objects' => ['controller', 'request'],
+ ]);
+
+ $this->setupTestApplication();
+});
+
+it('can generate domain request', function ($domainName, $requestName, $relativePath, $expectedNamespace) {
+ $expectedPath = base_path($relativePath);
+
+ if (file_exists($expectedPath)) {
+ unlink($expectedPath);
+ }
+
+ expect(file_exists($expectedPath))->toBeFalse();
+
+ Artisan::call("ddd:request {$domainName}:{$requestName}");
+
+ expect($output = Artisan::output())->when(
+ Feature::IncludeFilepathInGeneratorCommandOutput->exists(),
+ fn ($output) => $output->toContainFilepath($relativePath),
+ );
+
+ expect(file_exists($expectedPath))->toBeTrue();
+
+ expect(file_get_contents($expectedPath))
+ ->toContain("namespace {$expectedNamespace};");
+})->with([
+ 'Invoicing:StoreInvoiceRequest' => [
+ 'Invoicing',
+ 'StoreInvoiceRequest',
+ 'app/Modules/Invoicing/Requests/StoreInvoiceRequest.php',
+ 'App\Modules\Invoicing\Requests',
+ ],
+
+ 'Reporting.Internal:UpdateReportRequest' => [
+ 'Reporting.Internal',
+ 'UpdateReportRequest',
+ 'app/Modules/Reporting/Internal/Requests/UpdateReportRequest.php',
+ 'App\Modules\Reporting\Internal\Requests',
+ ],
+]);
diff --git a/tests/Generator/MakeValueObjectTest.php b/tests/Generator/ValueObjectMakeTest.php
similarity index 100%
rename from tests/Generator/MakeValueObjectTest.php
rename to tests/Generator/ValueObjectMakeTest.php
diff --git a/tests/Generator/MakeViewModelTest.php b/tests/Generator/ViewModelMakeTest.php
similarity index 100%
rename from tests/Generator/MakeViewModelTest.php
rename to tests/Generator/ViewModelMakeTest.php
diff --git a/tests/Support/DomainResolverTest.php b/tests/Support/DomainResolverTest.php
index 37b63b9..846c815 100644
--- a/tests/Support/DomainResolverTest.php
+++ b/tests/Support/DomainResolverTest.php
@@ -13,6 +13,11 @@
'--domain' => 'Customer',
]);
+ $this->artisan('ddd:value', [
+ 'name' => 'Subtotal',
+ '--domain' => 'Shared',
+ ]);
+
$this->expectedDomains = [
'Customer',
'Invoicing',
diff --git a/tests/Support/DomainTest.php b/tests/Support/DomainTest.php
index e263500..8a701ae 100644
--- a/tests/Support/DomainTest.php
+++ b/tests/Support/DomainTest.php
@@ -1,5 +1,6 @@
'app/Modules',
+ 'namespace' => 'App\Modules',
+ 'objects' => ['controller', 'request'],
+ ]);
+ });
+
+ it('can describe objects in the application layer', function ($domainName, $objectType, $objectName, $expectedFQN, $expectedPath) {
+ expect((new Domain($domainName))->object($objectType, $objectName))
+ ->name->toBe($objectName)
+ ->fullyQualifiedName->toBe($expectedFQN)
+ ->path->toBe(Path::normalize($expectedPath));
+ })->with([
+ ['Invoicing', 'controller', 'InvoiceController', 'App\\Modules\\Invoicing\\Controllers\\InvoiceController', 'app/Modules/Invoicing/Controllers/InvoiceController.php'],
+ ['Invoicing', 'controller', 'Nested\\InvoiceController', 'App\\Modules\\Invoicing\\Controllers\\Nested\\InvoiceController', 'app/Modules/Invoicing/Controllers/Nested/InvoiceController.php'],
+ ['Invoicing', 'request', 'StoreInvoiceRequest', 'App\\Modules\\Invoicing\\Requests\\StoreInvoiceRequest', 'app/Modules/Invoicing/Requests/StoreInvoiceRequest.php'],
+ ['Invoicing', 'request', 'Nested\\StoreInvoiceRequest', 'App\\Modules\\Invoicing\\Requests\\Nested\\StoreInvoiceRequest', 'app/Modules/Invoicing/Requests/Nested/StoreInvoiceRequest.php'],
+ ]);
+});
+
+it('normalizes slashes in nested objects', function ($nameInput, $normalized) {
+ expect((new Domain('Invoicing'))->object('class', $nameInput))
+ ->name->toBe($normalized);
+})->with([
+ ['Nested\\Thing', 'Nested\\Thing'],
+ ['Nested/Thing', 'Nested\\Thing'],
+ ['Nested/Thing/Deeply', 'Nested\\Thing\\Deeply'],
+ ['Nested\\Thing/Deeply', 'Nested\\Thing\\Deeply'],
+]);
diff --git a/tests/TestCase.php b/tests/TestCase.php
index fb69cab..b04ef5a 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -151,6 +151,7 @@ protected function cleanSlate()
File::deleteDirectory(base_path('Custom'));
File::deleteDirectory(base_path('src/Domain'));
File::deleteDirectory(base_path('src/Domains'));
+ File::deleteDirectory(base_path('src/App'));
File::deleteDirectory(app_path('Models'));
File::deleteDirectory(base_path('bootstrap/cache/ddd'));