diff --git a/README.md b/README.md
index c0e124e..17ab342 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,7 @@ We provide a few helper commands:
- `php artisan modules:clear` — clear the module cache
- `php artisan modules:sync` — update project configs (like `phpunit.xml`) with module settings
- `php artisan modules:list` — list all modules
+- `php artisan modules:remove` — delete a module and remove it from your `composer.json`
#### Laravel “`make:`” Commands
diff --git a/src/Console/Commands/ModulesRemove.php b/src/Console/Commands/ModulesRemove.php
new file mode 100644
index 0000000..5524089
--- /dev/null
+++ b/src/Console/Commands/ModulesRemove.php
@@ -0,0 +1,102 @@
+module_registry->module($this->argument('name'));
+
+ if (null === $module) {
+ $this->error("Module '{$this->argument('name')}' does not exist.");
+ return 1;
+ }
+
+ if (! $this->option('force') && ! $this->confirm("This will permanently delete {$module->base_path}. Continue?", false)) {
+ $this->line('Module was not removed.');
+ return 1;
+ }
+
+ $composer_name = $this->composerName($module);
+
+ $this->filesystem->deleteDirectory($module->base_path);
+ $this->line(" - Deleted {$module->base_path}");
+
+ if ($composer_name) {
+ $this->removeFromCoreComposerConfig($composer_name);
+ }
+
+ $this->call(ModulesClear::class);
+
+ $this->module_registry->reload();
+
+ if ($composer_name) {
+ $this->newLine();
+ $this->line("Please run composer update {$composer_name}");
+ $this->newLine();
+ }
+
+ return 0;
+ }
+
+ protected function composerName(ModuleConfig $module): ?string
+ {
+ $composer_file = $module->path('composer.json');
+
+ if (! $this->filesystem->isFile($composer_file)) {
+ return null;
+ }
+
+ return json_decode($this->filesystem->get($composer_file), true)['name'] ?? null;
+ }
+
+ protected function removeFromCoreComposerConfig(string $composer_name): void
+ {
+ // We're going to move into the Laravel base directory while
+ // we're updating the composer file so that we're sure we update
+ // the correct composer.json file (we'll restore CWD at the end)
+ $original_working_dir = getcwd();
+ chdir($this->laravel->basePath());
+
+ $json_file = new JsonFile(Factory::getComposerFile());
+ $definition = $json_file->read();
+
+ $has_changes = false;
+
+ foreach (['require', 'require-dev'] as $key) {
+ if (isset($definition[$key][$composer_name])) {
+ unset($definition[$key][$composer_name]);
+ $this->line(" - Removed {$composer_name} from {$key}");
+ $has_changes = true;
+ }
+ }
+
+ if ($has_changes) {
+ $json_file->write($definition);
+ $this->line(" - Wrote to {$json_file->getPath()}");
+ }
+
+ chdir($original_working_dir);
+ }
+}
diff --git a/src/Support/ModularServiceProvider.php b/src/Support/ModularServiceProvider.php
index 33bf966..6fee656 100644
--- a/src/Support/ModularServiceProvider.php
+++ b/src/Support/ModularServiceProvider.php
@@ -12,6 +12,7 @@
use InterNACHI\Modular\Console\Commands\ModulesCache;
use InterNACHI\Modular\Console\Commands\ModulesClear;
use InterNACHI\Modular\Console\Commands\ModulesList;
+use InterNACHI\Modular\Console\Commands\ModulesRemove;
use InterNACHI\Modular\Console\Commands\ModulesSync;
use InterNACHI\Modular\PluginRegistry;
use InterNACHI\Modular\Plugins\ArtisanPlugin;
@@ -102,6 +103,7 @@ public function boot(): void
ModulesClear::class,
ModulesSync::class,
ModulesList::class,
+ ModulesRemove::class,
]);
}
}
diff --git a/tests/Commands/ModulesRemoveTest.php b/tests/Commands/ModulesRemoveTest.php
new file mode 100644
index 0000000..ec0162d
--- /dev/null
+++ b/tests/Commands/ModulesRemoveTest.php
@@ -0,0 +1,78 @@
+makeModule('test-module');
+
+ $this->assertTrue($this->filesystem()->isDirectory($module->base_path));
+ $this->assertArrayHasKey('modules/test-module', $this->appComposerRequirements());
+
+ $this->artisan(ModulesRemove::class, ['name' => 'test-module', '--force' => true])
+ ->assertExitCode(0);
+
+ $this->assertFalse($this->filesystem()->isDirectory($module->base_path));
+ $this->assertArrayNotHasKey('modules/test-module', $this->appComposerRequirements());
+ $this->assertNull(Modules::module('test-module'));
+ }
+
+ public function test_it_only_removes_the_given_module(): void
+ {
+ $this->makeModule('test-module');
+ $other = $this->makeModule('other-module');
+
+ $this->artisan(ModulesRemove::class, ['name' => 'test-module', '--force' => true])
+ ->assertExitCode(0);
+
+ $this->assertTrue($this->filesystem()->isDirectory($other->base_path));
+ $this->assertArrayHasKey('modules/other-module', $this->appComposerRequirements());
+ $this->assertNotNull(Modules::module('other-module'));
+ }
+
+ public function test_it_asks_for_confirmation(): void
+ {
+ $module = $this->makeModule('test-module');
+
+ $this->artisan(ModulesRemove::class, ['name' => 'test-module'])
+ ->expectsConfirmation("This will permanently delete {$module->base_path}. Continue?", 'yes')
+ ->assertExitCode(0);
+
+ $this->assertFalse($this->filesystem()->isDirectory($module->base_path));
+ }
+
+ public function test_it_does_not_remove_the_module_if_confirmation_is_declined(): void
+ {
+ $module = $this->makeModule('test-module');
+
+ $this->artisan(ModulesRemove::class, ['name' => 'test-module'])
+ ->expectsConfirmation("This will permanently delete {$module->base_path}. Continue?", 'no')
+ ->assertExitCode(1);
+
+ $this->assertTrue($this->filesystem()->isDirectory($module->base_path));
+ $this->assertArrayHasKey('modules/test-module', $this->appComposerRequirements());
+ }
+
+ public function test_it_fails_for_unknown_modules(): void
+ {
+ $this->artisan(ModulesRemove::class, ['name' => 'does-not-exist', '--force' => true])
+ ->expectsOutput("Module 'does-not-exist' does not exist.")
+ ->assertExitCode(1);
+ }
+
+ protected function appComposerRequirements(): array
+ {
+ $composer_file = $this->getApplicationBasePath().'/composer.json';
+
+ return json_decode($this->filesystem()->get($composer_file), true)['require'] ?? [];
+ }
+}